feat: improve template marketplace UI/UX with badges, 'Modified' status, and read-only marketplace metadata

This commit is contained in:
Aleix Soler 2026-03-18 16:45:47 +01:00
parent 4cf73df730
commit 6ab760df98
6 changed files with 167 additions and 31 deletions

View file

@ -53,11 +53,16 @@ export const MarketplaceBrowseModal: React.FC<MarketplaceBrowseModalProps> = ({
<div className="marketplace-card-title">
{template.name}
</div>
{template.author && (
<div className="marketplace-card-author">
by {template.author}
</div>
)}
<div className="marketplace-card-meta">
<span className="template-badge badge-info">
Marketplace
</span>
{template.author && (
<div className="marketplace-card-author">
by {template.author}
</div>
)}
</div>
</div>
<div className="marketplace-card-description">
{template.description}

View file

@ -1,9 +1,10 @@
import React, { useState, useCallback } from "react";
import React, { useState, useCallback, useMemo } from "react";
import { StatusTemplate } from "@/types/pluginSettings";
import { NoteStatus } from "@/types/noteStatus";
import { Input } from "@/components/atoms/Input";
import { SettingItem } from "./SettingItem";
import { CustomStatusItem } from "./CustomStatusItem";
import { isCustomTemplate } from "@/utils/templateUtils";
interface TemplateEditorModalProps {
template?: StatusTemplate;
@ -16,6 +17,10 @@ export const TemplateEditorModal: React.FC<TemplateEditorModalProps> = ({
onSave,
onCancel,
}) => {
const isCustom = useMemo(
() => (template ? isCustomTemplate(template) : true),
[template],
);
const [name, setName] = useState(template?.name || "");
const [description, setDescription] = useState(template?.description || "");
const [author, setAuthor] = useState(template?.author || "");
@ -151,25 +156,35 @@ export const TemplateEditorModal: React.FC<TemplateEditorModalProps> = ({
<SettingItem
name="Author (Optional)"
description="Your name or community name"
description={
isCustom
? "Your name or community name"
: "Original author of the marketplace template"
}
>
<Input
variant="text"
value={author}
onChange={setAuthor}
placeholder="e.g. Jane Doe"
disabled={!isCustom}
/>
</SettingItem>
<SettingItem
name="GitHub URL (Optional)"
description="Link to your GitHub profile or repository"
description={
isCustom
? "Link to your GitHub profile or repository"
: "Official repository of the marketplace template"
}
>
<Input
variant="text"
value={github}
onChange={setGithub}
placeholder="e.g. https://github.com/janedoe"
disabled={!isCustom}
/>
</SettingItem>

View file

@ -2,7 +2,7 @@ import React, { useMemo } from "react";
import { StatusTemplate } from "@/types/pluginSettings";
import { StatusDisplay } from "../atoms/StatusDisplay";
import { SelectableListItem } from "../atoms/SelectableListItem";
import { isCustomTemplate } from "@/utils/templateUtils";
import { isCustomTemplate, isTemplateModified } from "@/utils/templateUtils";
import { ObsidianIcon } from "../atoms/ObsidianIcon";
interface TemplateItemProps {
@ -22,9 +22,10 @@ export const TemplateItem: React.FC<TemplateItemProps> = ({
onDelete,
onShare,
}) => {
const isCustom = useMemo(
() => isCustomTemplate(template.id),
[template.id],
const isCustom = useMemo(() => isCustomTemplate(template), [template]);
const isModified = useMemo(
() => !isCustom && isTemplateModified(template),
[isCustom, template],
);
const handleClick = (e: React.MouseEvent) => {
@ -36,7 +37,7 @@ export const TemplateItem: React.FC<TemplateItemProps> = ({
};
return (
<div className={`template-item ${isEnabled ? "enabled" : ""}`}>
<div className={`template-item ${isEnabled ? "enabled" : "disabled"}`}>
<SelectableListItem
selected={isEnabled}
onClick={handleClick}
@ -55,11 +56,31 @@ export const TemplateItem: React.FC<TemplateItemProps> = ({
<span className="setting-item-name">
{template.name}
</span>
{isCustomTemplate(template.id) && (
<span className="template-custom-badge">
Custom
</span>
)}
<div className="template-badges">
{isEnabled ? (
<span className="template-badge badge-success">
Active
</span>
) : (
<span className="template-badge badge-muted">
Inactive
</span>
)}
{isCustom ? (
<span className="template-badge badge-accent">
Custom
</span>
) : (
<span className="template-badge badge-info">
Marketplace
</span>
)}
{isModified && (
<span className="template-badge badge-warning">
Modified
</span>
)}
</div>
</div>
<div className="setting-item-description">
{template.description}

View file

@ -11,6 +11,7 @@ interface BaseInputProps {
style?: React.CSSProperties;
onFocus?: () => void;
onBlur?: () => void;
disabled?: boolean;
}
interface TextInputProps extends BaseInputProps {
@ -38,6 +39,7 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
style,
onFocus,
onBlur,
disabled,
},
ref,
) => {
@ -67,6 +69,7 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
style={style}
onFocus={onFocus}
onBlur={onBlur}
disabled={disabled}
/>
);
},

View file

@ -84,12 +84,20 @@
box-shadow: 0 2px 8px var(--shadow-color);
}
.template-item.disabled {
opacity: 0.85;
}
.template-item.enabled {
background: var(--background-modifier-hover);
border-color: var(--interactive-accent);
box-shadow: 0 0 0 1px var(--interactive-accent);
}
.template-item.enabled .setting-item-name {
font-weight: var(--font-bold);
}
.template-item.enabled:hover {
box-shadow:
0 2px 8px var(--shadow-color),
@ -114,20 +122,47 @@
margin-bottom: var(--size-2-1);
}
.template-custom-badge {
.template-badges {
display: flex;
gap: var(--size-2-2);
}
.template-badge {
display: inline-flex;
align-items: center;
gap: var(--size-2-1);
padding: var(--size-2-1) var(--size-2-2);
background: var(--interactive-accent);
color: var(--text-on-accent);
font-size: var(--font-ui-smaller);
font-weight: var(--font-medium);
padding: 2px 8px;
border-radius: var(--radius-s);
font-size: 10px;
font-weight: var(--font-bold);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.badge-success {
background-color: var(--background-modifier-success);
color: var(--text-on-accent);
}
.badge-muted {
background-color: var(--background-modifier-border);
color: var(--text-muted);
}
.badge-accent {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
}
.badge-warning {
background-color: var(--background-modifier-warning);
color: var(--text-on-accent);
}
.badge-info {
background-color: var(--background-modifier-info);
color: var(--text-on-accent);
}
/* Template Item Actions */
.template-item-actions {
display: flex;
@ -386,13 +421,20 @@
}
.marketplace-card-header {
margin-bottom: var(--size-2-2);
margin-bottom: var(--size-4-2);
}
.marketplace-card-title {
font-weight: var(--font-bold);
font-size: var(--font-ui-medium);
font-size: var(--font-ui-large);
color: var(--text-normal);
margin-bottom: var(--size-2-2);
}
.marketplace-card-meta {
display: flex;
align-items: center;
gap: var(--size-2-2);
}
.marketplace-card-author {

View file

@ -3,11 +3,61 @@ import settingsService from "@/core/settingsService";
import { PREDEFINED_TEMPLATES } from "@/constants/predefinedTemplates";
/**
* Get all enabled templates
* Check if a template is a custom user template (not from marketplace)
*/
export const isCustomTemplate = (templateId: string): boolean => {
const i = PREDEFINED_TEMPLATES.findIndex((t) => t.id === templateId);
return i === -1;
export const isCustomTemplate = (template: StatusTemplate): boolean => {
// Either matches an original marketplace template ID
const matchesPredefined = PREDEFINED_TEMPLATES.some(
(pt) =>
pt.id === template.id ||
pt.name.toLowerCase() === template.name.toLowerCase(),
);
if (matchesPredefined) return false;
// Or has metadata from marketplace
const hasMetadata = !!(template.author || template.github);
if (hasMetadata) return false;
return true;
};
/**
* Check if a marketplace template has been modified by the user
*/
export const isTemplateModified = (template: StatusTemplate): boolean => {
const original = PREDEFINED_TEMPLATES.find(
(pt) =>
pt.id === template.id ||
pt.name.toLowerCase() === template.name.toLowerCase(),
);
if (!original) return false;
// Compare relevant fields to see if anything changed
// We stringify for a deep comparison of statuses
const originalData = JSON.stringify({
name: original.name,
description: original.description,
statuses: original.statuses.map((s) => ({
name: s.name,
icon: s.icon,
lucideIcon: s.lucideIcon,
color: s.color,
})),
});
const currentData = JSON.stringify({
name: template.name,
description: template.description,
statuses: template.statuses.map((s) => ({
name: s.name,
icon: s.icon,
lucideIcon: s.lucideIcon,
color: s.color,
})),
});
return originalData !== currentData;
};
/**