feat: implement Template Marketplace gallery with installation workflow

This commit is contained in:
Aleix Soler 2026-03-18 15:57:07 +01:00
parent 0d5cf5a0d8
commit e089efd85c
4 changed files with 272 additions and 29 deletions

View file

@ -0,0 +1,114 @@
import React, { useState, useMemo } from "react";
import { StatusTemplate } from "@/types/pluginSettings";
import { PREDEFINED_TEMPLATES } from "@/constants/predefinedTemplates";
import { StatusDisplay } from "../atoms/StatusDisplay";
import { ObsidianIcon } from "../atoms/ObsidianIcon";
import { SearchFilter } from "../atoms/SearchFilter";
interface MarketplaceBrowseModalProps {
installedIds: string[];
onInstall: (template: StatusTemplate) => void;
onClose: () => void;
}
export const MarketplaceBrowseModal: React.FC<MarketplaceBrowseModalProps> = ({
installedIds,
onInstall,
onClose,
}) => {
const [searchQuery, setSearchQuery] = useState("");
const filteredTemplates = useMemo(() => {
return PREDEFINED_TEMPLATES.filter(
(t) =>
t.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
t.description.toLowerCase().includes(searchQuery.toLowerCase()),
);
}, [searchQuery]);
return (
<div className="template-editor-modal marketplace-browse-modal">
<div className="template-editor-modal__header">
<div className="marketplace-header-content">
<h2>Template Marketplace</h2>
<SearchFilter
value={searchQuery}
onChange={setSearchQuery}
placeholder="Search templates..."
/>
</div>
</div>
<div className="template-editor-modal__content">
<div className="marketplace-grid">
{filteredTemplates.map((template) => {
const isInstalled =
installedIds.includes(template.id) ||
installedIds.includes(`custom-${template.id}`);
return (
<div key={template.id} className="marketplace-card">
<div className="marketplace-card-header">
<div className="marketplace-card-title">
{template.name}
</div>
{template.author && (
<div className="marketplace-card-author">
by {template.author}
</div>
)}
</div>
<div className="marketplace-card-description">
{template.description}
</div>
<div className="marketplace-card-statuses">
{template.statuses.map((status, idx) => (
<StatusDisplay
key={idx}
status={status}
variant="template"
/>
))}
</div>
<div className="marketplace-card-actions">
<button
className={`marketplace-install-btn ${
isInstalled
? "installed"
: "mod-cta"
}`}
onClick={() =>
!isInstalled && onInstall(template)
}
disabled={isInstalled}
>
<ObsidianIcon
name={
isInstalled
? "check"
: "download"
}
size={16}
/>
{isInstalled
? "Installed"
: "Install Template"}
</button>
</div>
</div>
);
})}
</div>
{filteredTemplates.length === 0 && (
<div className="marketplace-empty">
<ObsidianIcon name="search-slash" size={48} />
<p>No templates found matching your search.</p>
</div>
)}
</div>
<div className="template-editor-modal__actions">
<button onClick={onClose}>Close</button>
</div>
</div>
);
};

View file

@ -3,14 +3,11 @@ import { PluginSettings, StatusTemplate } from "@/types/pluginSettings";
import { TemplateItem } from "./TemplateItem";
import { TemplateEditorModal } from "./TemplateEditorModal";
import { MarketplaceShareModal } from "./MarketplaceShareModal";
import { MarketplaceBrowseModal } from "./MarketplaceBrowseModal";
import {
generateTemplateId,
isTemplateNameUnique,
} from "@/utils/templateUtils";
import {
DEFAULT_ENABLED_TEMPLATES,
PREDEFINED_TEMPLATES,
} from "@/constants/predefinedTemplates";
interface TemplateSettingsProps {
settings: PluginSettings;
@ -23,6 +20,7 @@ export const TemplateSettings: React.FC<TemplateSettingsProps> = ({
}) => {
const [showEditor, setShowEditor] = useState(false);
const [showShare, setShowShare] = useState(false);
const [showMarketplace, setShowMarketplace] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<
StatusTemplate | undefined
>();
@ -62,6 +60,24 @@ export const TemplateSettings: React.FC<TemplateSettingsProps> = ({
setShowShare(true);
}, []);
const handleInstallTemplate = useCallback(
(template: StatusTemplate) => {
const newId = generateTemplateId(template.name);
const installedTemplate: StatusTemplate = {
...template,
id: newId,
statuses: template.statuses.map((s) => ({
...s,
templateId: newId,
})),
};
onChange("templates", [...settings.templates, installedTemplate]);
handleTemplateToggle(newId, true);
},
[settings.templates, onChange, handleTemplateToggle],
);
const handleSaveTemplate = useCallback(
(template: StatusTemplate) => {
// Validate name uniqueness
@ -128,19 +144,6 @@ export const TemplateSettings: React.FC<TemplateSettingsProps> = ({
[settings.templates, settings.enabledTemplates, onChange],
);
const handleResetToDefaults = useCallback(() => {
const confirmed = confirm(
"Reset to default templates? This will:\n" +
"• Remove all custom templates\n" +
"• Reset enabled templates to defaults\n" +
"This action cannot be undone.",
);
if (!confirmed) return;
onChange("templates", [...PREDEFINED_TEMPLATES]);
onChange("enabledTemplates", [...DEFAULT_ENABLED_TEMPLATES]);
}, [onChange]);
const handleCancelEditor = useCallback(() => {
setShowEditor(false);
setEditingTemplate(undefined);
@ -151,6 +154,14 @@ export const TemplateSettings: React.FC<TemplateSettingsProps> = ({
setSharingTemplate(undefined);
}, []);
const handleOpenMarketplace = useCallback(() => {
setShowMarketplace(true);
}, []);
const handleCloseMarketplace = useCallback(() => {
setShowMarketplace(false);
}, []);
if (showEditor) {
return (
<div>
@ -176,31 +187,43 @@ export const TemplateSettings: React.FC<TemplateSettingsProps> = ({
);
}
if (showMarketplace) {
return (
<div>
<h3>Status templates</h3>
<MarketplaceBrowseModal
installedIds={settings.templates.map((t) => t.id)}
onInstall={handleInstallTemplate}
onClose={handleCloseMarketplace}
/>
</div>
);
}
return (
<div>
<h3>Status templates</h3>
<p>
Enable predefined templates to quickly add common status
workflows, or create your own custom templates.
Browse the marketplace to find common status workflows or create
your own custom templates.
</p>
{/* Custom Templates Section */}
<div className="template-section">
<h4 className="template-section-title">Custom Templates</h4>
<div className="template-settings-actions">
<button
className="mod-cta template-create-btn"
className="mod-cta marketplace-browse-btn"
onClick={handleOpenMarketplace}
>
<ObsidianIcon name="globe" size={16} />
Browse Marketplace
</button>
<button
className="template-create-btn"
onClick={handleCreateTemplate}
>
+ Create Template
</button>
<button
className="template-reset-btn"
onClick={handleResetToDefaults}
>
🔄 Reset to Defaults
</button>
</div>
<div className="template-list">
{(settings.templates || []).map((template) => (

File diff suppressed because one or more lines are too long

View file

@ -349,6 +349,112 @@
text-decoration: none !important;
}
/* Marketplace Browse Modal */
.marketplace-header-content {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
gap: var(--size-4-4);
}
.marketplace-header-content h2 {
margin: 0;
white-space: nowrap;
}
.marketplace-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: var(--size-4-4);
padding: var(--size-2-2);
}
.marketplace-card {
display: flex;
flex-direction: column;
background: var(--background-primary-alt);
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-m);
padding: var(--size-4-4);
transition: all var(--anim-duration-fast) ease;
}
.marketplace-card:hover {
border-color: var(--interactive-accent);
box-shadow: var(--shadow-s);
}
.marketplace-card-header {
margin-bottom: var(--size-2-2);
}
.marketplace-card-title {
font-weight: var(--font-bold);
font-size: var(--font-ui-medium);
color: var(--text-normal);
}
.marketplace-card-author {
font-size: var(--font-ui-smaller);
color: var(--text-muted);
font-style: italic;
}
.marketplace-card-description {
font-size: var(--font-ui-small);
color: var(--text-muted);
margin-bottom: var(--size-4-2);
flex-grow: 1;
}
.marketplace-card-statuses {
display: flex;
flex-wrap: wrap;
gap: var(--size-2-1);
margin-bottom: var(--size-4-4);
}
.marketplace-card-actions {
margin-top: auto;
}
.marketplace-install-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: var(--size-2-2);
}
.marketplace-install-btn.installed {
background: var(--background-modifier-success-accent) !important;
color: var(--text-on-accent) !important;
opacity: 0.8;
cursor: default;
}
.marketplace-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--size-4-12);
color: var(--text-muted);
text-align: center;
}
.marketplace-empty p {
margin-top: var(--size-4-2);
font-size: var(--font-ui-large);
}
.marketplace-browse-btn {
display: flex;
align-items: center;
gap: var(--size-2-2);
}
/* Responsive Design */
@media (max-width: 768px) {
.template-settings-actions {