feat: prepare Zhinian desktop pilot
This commit is contained in:
163
src/pages/AppCenter/index.tsx
Normal file
163
src/pages/AppCenter/index.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
Clapperboard,
|
||||
LayoutGrid,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
YinianPageHeader,
|
||||
YinianPageShell,
|
||||
YinianPanel,
|
||||
} from '@/components/yinian/ui';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppCenterStore } from '@/stores/app-center';
|
||||
import type { AppCenterItem } from '@/types/app-center';
|
||||
|
||||
const APP_ICONS = {
|
||||
Clapperboard,
|
||||
LayoutGrid,
|
||||
};
|
||||
|
||||
function getAppIcon(icon: string) {
|
||||
return APP_ICONS[icon as keyof typeof APP_ICONS] ?? LayoutGrid;
|
||||
}
|
||||
|
||||
export function AppCenter() {
|
||||
const { t } = useTranslation('appCenter');
|
||||
const navigate = useNavigate();
|
||||
const init = useAppCenterStore((state) => state.init);
|
||||
const items = useAppCenterStore((state) => state.items);
|
||||
const selectedTagKey = useAppCenterStore((state) => state.selectedTagKey);
|
||||
const selectTag = useAppCenterStore((state) => state.selectTag);
|
||||
|
||||
useEffect(() => {
|
||||
init();
|
||||
}, [init]);
|
||||
|
||||
const categoryCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const item of items) {
|
||||
counts.set(item.categoryKey, (counts.get(item.categoryKey) ?? 0) + 1);
|
||||
}
|
||||
return [...counts.entries()];
|
||||
}, [items]);
|
||||
const filteredItems = useMemo(() => (
|
||||
selectedTagKey === 'all'
|
||||
? items
|
||||
: items.filter((item) => item.tagKeys.includes(selectedTagKey))
|
||||
), [items, selectedTagKey]);
|
||||
|
||||
const openItem = (item: AppCenterItem) => {
|
||||
if (item.type === 'external' && item.url) {
|
||||
void window.electron.openExternal(item.url);
|
||||
return;
|
||||
}
|
||||
if (item.type === 'native' && item.route) {
|
||||
navigate(item.route);
|
||||
return;
|
||||
}
|
||||
if (item.type === 'webview' && item.url) {
|
||||
void window.electron.openExternal(item.url);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<YinianPageShell data-testid="app-center-page" className="h-full max-w-none overflow-hidden">
|
||||
<YinianPageHeader className="shrink-0 border-b-0 pb-0">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-normal text-slate-950 dark:text-slate-50 md:text-3xl">
|
||||
{t('title')}
|
||||
</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</YinianPageHeader>
|
||||
|
||||
<section className="min-h-0 flex-1 overflow-hidden">
|
||||
<YinianPanel className="min-h-0 overflow-hidden p-4">
|
||||
<div className="flex h-full min-h-0 flex-col gap-4">
|
||||
<div className="flex shrink-0 flex-col items-start gap-3">
|
||||
<div className="flex max-w-[720px] flex-wrap justify-start gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectTag('all')}
|
||||
className={cn(
|
||||
'rounded-lg border px-2.5 py-1 text-xs font-medium transition-colors',
|
||||
selectedTagKey === 'all'
|
||||
? 'border-[#7DBADB] bg-white text-[#075985] shadow-sm'
|
||||
: 'border-slate-200/80 bg-white/60 text-slate-600 hover:bg-white dark:border-white/10 dark:bg-white/5 dark:text-slate-300 dark:hover:bg-white/20',
|
||||
)}
|
||||
>
|
||||
{t('tags.all')} · {items.length}
|
||||
</button>
|
||||
{categoryCounts.map(([categoryKey, count]) => {
|
||||
const tagKey = categoryKey.replace('categories.', 'tags.');
|
||||
return (
|
||||
<button
|
||||
key={categoryKey}
|
||||
type="button"
|
||||
onClick={() => selectTag(tagKey)}
|
||||
className={cn(
|
||||
'rounded-lg border px-2.5 py-1 text-xs font-medium transition-colors',
|
||||
selectedTagKey === tagKey
|
||||
? 'border-[#7DBADB] bg-white text-[#075985] shadow-sm'
|
||||
: 'border-slate-200/80 bg-white/60 text-slate-600 hover:bg-white dark:border-white/10 dark:bg-white/5 dark:text-slate-300 dark:hover:bg-white/20',
|
||||
)}
|
||||
>
|
||||
{t(categoryKey)} · {count}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 overflow-y-auto pr-1">
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(156px,1fr))] gap-3 pb-1">
|
||||
{filteredItems.map((item) => {
|
||||
const Icon = getAppIcon(item.icon);
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
openItem(item);
|
||||
}}
|
||||
className={cn(
|
||||
'group relative flex min-h-[164px] flex-col items-center overflow-hidden rounded-lg border bg-white px-3 pb-10 pt-4 text-center shadow-none transition-all duration-200',
|
||||
'hover:-translate-y-0.5 hover:border-[#7DBADB] hover:bg-white hover:shadow-[0_16px_36px_rgba(15,23,42,0.08)]',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1E3A8A]/30',
|
||||
'border-slate-200/80 dark:border-white/10 dark:bg-slate-950/75 dark:hover:shadow-[0_10px_24px_rgba(0,0,0,0.24)]',
|
||||
)}
|
||||
>
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-[#0369A1] text-white shadow-[0_8px_18px_rgba(3,105,161,0.18)]">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="mt-3 min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-slate-950 dark:text-slate-50">
|
||||
{t(item.nameKey)}
|
||||
</div>
|
||||
<p className="mx-auto mt-1 line-clamp-2 max-w-[13rem] text-xs leading-4 text-muted-foreground">
|
||||
{t(item.descriptionKey)}
|
||||
</p>
|
||||
</div>
|
||||
<span className="absolute bottom-3 left-1/2 inline-flex h-7 -translate-x-1/2 items-center gap-1 rounded-lg bg-[#0369A1] px-2.5 text-[11px] font-medium text-white opacity-0 shadow-[0_8px_16px_rgba(3,105,161,0.16)] transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
|
||||
{t('actions.open')}
|
||||
<ArrowUpRight className="h-3 w-3" />
|
||||
</span>
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-10 bg-gradient-to-t from-[#0369A1]/[0.06] to-transparent opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100 dark:from-blue-300/[0.08]" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</YinianPanel>
|
||||
</section>
|
||||
</YinianPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppCenter;
|
||||
Reference in New Issue
Block a user