mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 03:55:55 +00:00
feat(extensions): mobile-friendly layout for extensions and add-extension pages
- Stack the extensions page header vertically on small screens, let the filter Tabs scroll horizontally if they overflow, hide the debug button label below sm and let the install/debug controls wrap. - Constrain the debug popover and its inputs to the viewport width so they no longer overflow on phone-sized screens. - Drop the card grid from a fixed 30rem column to a min(100%, 22rem) column at base / 28rem at sm, and reduce the gap, so cards render cleanly at 360px+ widths in both flat and grouped views. - Make the add-extension header actions wrap on lg- viewports and the install dialog responsive instead of a hard 500px box. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -56,7 +56,9 @@ function AddExtensionContent() {
|
||||
} = usePluginInstallTasks();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [installInfo, setInstallInfo] = useState<Record<string, string>>({});
|
||||
const [installExtensionType, setInstallExtensionType] = useState<'plugin' | 'mcp' | 'skill'>('plugin');
|
||||
const [installExtensionType, setInstallExtensionType] = useState<
|
||||
'plugin' | 'mcp' | 'skill'
|
||||
>('plugin');
|
||||
const [pluginInstallStatus, setPluginInstallStatus] =
|
||||
useState<PluginInstallStatus>(PluginInstallStatus.ASK_CONFIRM);
|
||||
const [installError, setInstallError] = useState<string | null>(null);
|
||||
@@ -120,29 +122,38 @@ function AddExtensionContent() {
|
||||
<>
|
||||
<Button
|
||||
variant="default"
|
||||
className="px-4 py-2 cursor-pointer"
|
||||
className="px-3 sm:px-4 py-2 cursor-pointer flex-shrink-0"
|
||||
onClick={() => navigate('/home/mcp?id=new')}
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
{t('mcp.addMCPServer')}
|
||||
<span className="whitespace-nowrap">{t('mcp.addMCPServer')}</span>
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="default" className="px-4 py-2 cursor-pointer">
|
||||
<Button
|
||||
variant="default"
|
||||
className="px-3 sm:px-4 py-2 cursor-pointer flex-shrink-0"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
{t('skills.addSkill')}
|
||||
<ChevronDownIcon className="ml-2 w-4 h-4" />
|
||||
<span className="whitespace-nowrap">{t('skills.addSkill')}</span>
|
||||
<ChevronDownIcon className="ml-1 sm:ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => navigate('/home/skills?action=create')}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate('/home/skills?action=create')}
|
||||
>
|
||||
{t('skills.createManually')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => navigate('/home/skills?action=upload')}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate('/home/skills?action=upload')}
|
||||
>
|
||||
{t('skills.uploadZip')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => navigate('/home/skills?action=github')}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate('/home/skills?action=github')}
|
||||
>
|
||||
{t('skills.importFromGithub')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
@@ -150,17 +161,24 @@ function AddExtensionContent() {
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="default" className="px-4 py-2 cursor-pointer">
|
||||
<Button
|
||||
variant="default"
|
||||
className="px-3 sm:px-4 py-2 cursor-pointer flex-shrink-0"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
{t('plugins.newPlugin')}
|
||||
<ChevronDownIcon className="ml-2 w-4 h-4" />
|
||||
<span className="whitespace-nowrap">{t('plugins.newPlugin')}</span>
|
||||
<ChevronDownIcon className="ml-1 sm:ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => navigate('/home/add-plugin?action=github')}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate('/home/add-plugin?action=github')}
|
||||
>
|
||||
{t('plugins.installFromGithub')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => navigate('/home/add-plugin?action=upload')}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate('/home/add-plugin?action=upload')}
|
||||
>
|
||||
{t('plugins.uploadLocal')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
@@ -172,7 +190,10 @@ function AddExtensionContent() {
|
||||
<>
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<MarketPage installPlugin={handleInstallPlugin} headerActions={extensionActions} />
|
||||
<MarketPage
|
||||
installPlugin={handleInstallPlugin}
|
||||
headerActions={extensionActions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -185,7 +206,7 @@ function AddExtensionContent() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="w-[500px] max-h-[80vh] p-6 bg-white dark:bg-[#1a1a1e] overflow-y-auto">
|
||||
<DialogContent className="w-[calc(100vw-2rem)] sm:w-[500px] sm:max-w-[500px] max-h-[80vh] p-4 sm:p-6 overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-4">
|
||||
<Download className="size-6" />
|
||||
|
||||
@@ -462,7 +462,7 @@ const PluginInstalledComponent = forwardRef<
|
||||
({group.items.length})
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-[0.8rem] grid gap-8 [grid-template-columns:repeat(auto-fill,minmax(30rem,1fr))] items-start">
|
||||
<div className="px-[0.8rem] grid gap-5 sm:gap-8 [grid-template-columns:repeat(auto-fill,minmax(min(100%,22rem),1fr))] sm:[grid-template-columns:repeat(auto-fill,minmax(min(100%,28rem),1fr))] items-start">
|
||||
{group.items.map((vo, index) => (
|
||||
<div key={vo.id || index}>
|
||||
<ExtensionCardComponent
|
||||
|
||||
@@ -14,10 +14,7 @@ import {
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from '@/components/ui/toggle-group';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import {
|
||||
Search,
|
||||
Wrench,
|
||||
@@ -153,39 +150,54 @@ function MarketPageContent({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const transformMCPToVO = useCallback((mcp: any): PluginMarketCardVO => {
|
||||
const transformMCPToVO = useCallback(
|
||||
(mcp: any): PluginMarketCardVO => {
|
||||
return new PluginMarketCardVO({
|
||||
pluginId: mcp.author + ' / ' + mcp.name,
|
||||
author: mcp.author,
|
||||
pluginName: mcp.name,
|
||||
label: extractI18nObject(mcp.label),
|
||||
description: extractI18nObject(mcp.description) || t('market.noDescription'),
|
||||
description:
|
||||
extractI18nObject(mcp.description) || t('market.noDescription'),
|
||||
installCount: mcp.install_count || 0,
|
||||
iconURL: mcp.icon || getCloudServiceClientSync().getPluginIconURL(mcp.author, mcp.name),
|
||||
iconURL:
|
||||
mcp.icon ||
|
||||
getCloudServiceClientSync().getPluginIconURL(mcp.author, mcp.name),
|
||||
githubURL: mcp.repository,
|
||||
version: mcp.latest_version,
|
||||
components: mcp.components || {},
|
||||
tags: mcp.tags || [],
|
||||
type: 'mcp',
|
||||
});
|
||||
}, [t]);
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const transformSkillToVO = useCallback((skill: any): PluginMarketCardVO => {
|
||||
const transformSkillToVO = useCallback(
|
||||
(skill: any): PluginMarketCardVO => {
|
||||
return new PluginMarketCardVO({
|
||||
pluginId: skill.author + ' / ' + skill.name,
|
||||
author: skill.author,
|
||||
pluginName: skill.name,
|
||||
label: extractI18nObject(skill.label),
|
||||
description: extractI18nObject(skill.description) || t('market.noDescription'),
|
||||
description:
|
||||
extractI18nObject(skill.description) || t('market.noDescription'),
|
||||
installCount: skill.install_count || 0,
|
||||
iconURL: skill.icon || getCloudServiceClientSync().getPluginIconURL(skill.author, skill.name),
|
||||
iconURL:
|
||||
skill.icon ||
|
||||
getCloudServiceClientSync().getPluginIconURL(
|
||||
skill.author,
|
||||
skill.name,
|
||||
),
|
||||
githubURL: skill.repository,
|
||||
version: skill.latest_version,
|
||||
components: skill.components || {},
|
||||
tags: skill.tags || [],
|
||||
type: 'skill',
|
||||
});
|
||||
}, [t]);
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
// 获取插件列表
|
||||
const fetchPlugins = useCallback(
|
||||
@@ -212,7 +224,8 @@ function MarketPageContent({
|
||||
let skillsTotal = 0;
|
||||
|
||||
try {
|
||||
const pluginsResponse = await getCloudServiceClientSync().searchMarketplacePlugins(
|
||||
const pluginsResponse =
|
||||
await getCloudServiceClientSync().searchMarketplacePlugins(
|
||||
query,
|
||||
page,
|
||||
pageSize,
|
||||
@@ -225,7 +238,10 @@ function MarketPageContent({
|
||||
pluginsResult = pluginsResponse.plugins
|
||||
.filter((plugin) => {
|
||||
const keys = Object.keys(plugin.components || {});
|
||||
return !(keys.length > 0 && keys.every((k) => k === 'KnowledgeRetriever'));
|
||||
return !(
|
||||
keys.length > 0 &&
|
||||
keys.every((k) => k === 'KnowledgeRetriever')
|
||||
);
|
||||
})
|
||||
.map(transformToVO);
|
||||
pluginsTotal = pluginsResponse.total || 0;
|
||||
@@ -234,7 +250,8 @@ function MarketPageContent({
|
||||
}
|
||||
|
||||
try {
|
||||
const mcpsResponse = await getCloudServiceClientSync().searchMarketplacePlugins(
|
||||
const mcpsResponse =
|
||||
await getCloudServiceClientSync().searchMarketplacePlugins(
|
||||
query,
|
||||
page,
|
||||
pageSize,
|
||||
@@ -251,7 +268,8 @@ function MarketPageContent({
|
||||
}
|
||||
|
||||
try {
|
||||
const skillsResponse = await getCloudServiceClientSync().searchMarketplacePlugins(
|
||||
const skillsResponse =
|
||||
await getCloudServiceClientSync().searchMarketplacePlugins(
|
||||
query,
|
||||
page,
|
||||
pageSize,
|
||||
@@ -261,7 +279,9 @@ function MarketPageContent({
|
||||
selectedTags.length > 0 ? selectedTags : undefined,
|
||||
'skill',
|
||||
);
|
||||
skillsResult = (skillsResponse.plugins || []).map(transformSkillToVO);
|
||||
skillsResult = (skillsResponse.plugins || []).map(
|
||||
transformSkillToVO,
|
||||
);
|
||||
skillsTotal = skillsResponse.total || 0;
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch skills:', e);
|
||||
@@ -270,7 +290,8 @@ function MarketPageContent({
|
||||
newPlugins = [...pluginsResult, ...mcpsResult, ...skillsResult];
|
||||
total = pluginsTotal + mcpsTotal + skillsTotal;
|
||||
} else {
|
||||
const response = await getCloudServiceClientSync().searchMarketplacePlugins(
|
||||
const response =
|
||||
await getCloudServiceClientSync().searchMarketplacePlugins(
|
||||
query,
|
||||
page,
|
||||
pageSize,
|
||||
@@ -285,7 +306,9 @@ function MarketPageContent({
|
||||
newPlugins = data.plugins
|
||||
.filter((plugin) => {
|
||||
const keys = Object.keys(plugin.components || {});
|
||||
return !(keys.length > 0 && keys.every((k) => k === 'KnowledgeRetriever'));
|
||||
return !(
|
||||
keys.length > 0 && keys.every((k) => k === 'KnowledgeRetriever')
|
||||
);
|
||||
})
|
||||
.map(transformToVO);
|
||||
total = data.total;
|
||||
@@ -300,7 +323,9 @@ function MarketPageContent({
|
||||
setTotal(total);
|
||||
setHasMore(
|
||||
newPlugins.length > 0 &&
|
||||
(reset || page === 1 ? newPlugins.length : plugins.length + newPlugins.length) < total,
|
||||
(reset || page === 1
|
||||
? newPlugins.length
|
||||
: plugins.length + newPlugins.length) < total,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch plugins:', error);
|
||||
@@ -455,12 +480,21 @@ function MarketPageContent({
|
||||
const pluginV4: PluginV4 = {
|
||||
id: 0,
|
||||
plugin_id: `${cardVO.author}/${cardVO.pluginName}`,
|
||||
mcp_id: cardVO.type === 'mcp' ? `${cardVO.author}/${cardVO.pluginName}` : undefined,
|
||||
skill_id: cardVO.type === 'skill' ? `${cardVO.author}/${cardVO.pluginName}` : undefined,
|
||||
mcp_id:
|
||||
cardVO.type === 'mcp'
|
||||
? `${cardVO.author}/${cardVO.pluginName}`
|
||||
: undefined,
|
||||
skill_id:
|
||||
cardVO.type === 'skill'
|
||||
? `${cardVO.author}/${cardVO.pluginName}`
|
||||
: undefined,
|
||||
author: cardVO.author,
|
||||
name: cardVO.pluginName,
|
||||
label: { en_US: cardVO.label, zh_Hans: cardVO.label },
|
||||
description: { en_US: cardVO.description, zh_Hans: cardVO.description },
|
||||
description: {
|
||||
en_US: cardVO.description,
|
||||
zh_Hans: cardVO.description,
|
||||
},
|
||||
icon: cardVO.iconURL,
|
||||
repository: cardVO.githubURL,
|
||||
tags: cardVO.tags || [],
|
||||
@@ -482,7 +516,10 @@ function MarketPageContent({
|
||||
cardVO.pluginName,
|
||||
);
|
||||
if (!response?.plugin) {
|
||||
console.error('Failed to install plugin: plugin not found', { author: cardVO.author, pluginName: cardVO.pluginName });
|
||||
console.error('Failed to install plugin: plugin not found', {
|
||||
author: cardVO.author,
|
||||
pluginName: cardVO.pluginName,
|
||||
});
|
||||
toast.error(t('market.installFailed'));
|
||||
return;
|
||||
}
|
||||
@@ -601,7 +638,7 @@ function MarketPageContent({
|
||||
/>
|
||||
</div>
|
||||
{headerActions && (
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="flex items-center gap-2 flex-wrap lg:flex-nowrap lg:flex-shrink-0">
|
||||
{headerActions}
|
||||
</div>
|
||||
)}
|
||||
@@ -631,7 +668,9 @@ function MarketPageContent({
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="relative flex-shrink-0">
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{t('market.filters.more')}</span>
|
||||
<span className="hidden sm:inline">
|
||||
{t('market.filters.more')}
|
||||
</span>
|
||||
{activeAdvancedFilters > 0 && (
|
||||
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] leading-none text-primary-foreground">
|
||||
{activeAdvancedFilters}
|
||||
@@ -641,7 +680,9 @@ function MarketPageContent({
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-[320px] space-y-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t('market.filters.advancedTitle')}</div>
|
||||
<div className="text-sm font-medium">
|
||||
{t('market.filters.advancedTitle')}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{t('market.filters.advancedDescription')}
|
||||
</div>
|
||||
@@ -735,8 +776,7 @@ function MarketPageContent({
|
||||
className="flex-1 overflow-y-auto px-3 sm:px-4"
|
||||
>
|
||||
{/* Recommendation Lists */}
|
||||
{!searchQuery &&
|
||||
selectedTags.length === 0 && (
|
||||
{!searchQuery && selectedTags.length === 0 && (
|
||||
<div className="pt-4">
|
||||
<RecommendationLists
|
||||
lists={recommendationLists}
|
||||
@@ -823,7 +863,10 @@ export default function MarketPage({
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<MarketPageContent installPlugin={installPlugin} headerActions={headerActions} />
|
||||
<MarketPageContent
|
||||
installPlugin={installPlugin}
|
||||
headerActions={headerActions}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -537,7 +537,8 @@ function PluginListView() {
|
||||
/>
|
||||
|
||||
{/* Header bar with filter tabs, debug info, and task queue */}
|
||||
<div className="flex flex-row justify-between items-center px-[0.8rem] pb-4 flex-shrink-0 gap-2">
|
||||
<div className="flex flex-col md:flex-row md:justify-between md:items-center px-[0.8rem] pb-4 flex-shrink-0 gap-2">
|
||||
<div className="overflow-x-auto -mx-1 px-1">
|
||||
<Tabs
|
||||
value={filterType}
|
||||
onValueChange={(value) => setFilterType(value as FilterType)}
|
||||
@@ -551,8 +552,9 @@ function PluginListView() {
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<div className="flex items-center gap-2 px-2">
|
||||
</div>
|
||||
<div className="flex flex-row items-center gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-2 px-1 sm:px-2">
|
||||
<Switch
|
||||
id="group-by-type"
|
||||
checked={groupByType}
|
||||
@@ -572,14 +574,19 @@ function PluginListView() {
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="px-4 py-4 cursor-pointer"
|
||||
className="px-3 sm:px-4 py-4 cursor-pointer"
|
||||
onClick={handleShowDebugInfo}
|
||||
>
|
||||
<Code className="w-4 h-4 mr-2" />
|
||||
<Code className="w-4 h-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">
|
||||
{t('plugins.debugInfo')}
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[380px]" align="end">
|
||||
<PopoverContent
|
||||
className="w-[calc(100vw-2rem)] max-w-[380px]"
|
||||
align="end"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{/* Header with icon and title */}
|
||||
<div className="flex items-center gap-2 pb-2 border-b">
|
||||
@@ -597,7 +604,7 @@ function PluginListView() {
|
||||
<Input
|
||||
value={debugInfo?.debug_url || ''}
|
||||
readOnly
|
||||
className="w-[220px] font-mono text-xs h-8"
|
||||
className="flex-1 min-w-0 font-mono text-xs h-8"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -14,8 +14,15 @@
|
||||
padding-top: 2rem;
|
||||
padding-bottom: 2rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(30rem, 1fr));
|
||||
gap: 2rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 22rem), 1fr));
|
||||
gap: 1.25rem;
|
||||
justify-items: stretch;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.pluginListContainer {
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 28rem), 1fr));
|
||||
gap: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user