From 766303536a7281e69480a7e1e87335b7265e2c40 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:19:49 +0800 Subject: [PATCH] feat(web): unify landing and dashboard experience Closes #824 Made-with: Proma Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../portal/NamespaceController.java | 9 + .../NamespacePortalQueryAppService.java | 20 + .../NamespacePortalControllerTest.java | 21 + .../domain/namespace/NamespaceRepository.java | 1 + .../infra/jpa/NamespaceJpaRepository.java | 1 + web/e2e/promotions-review.spec.ts | 4 +- web/e2e/theme-toggle.spec.ts | 11 +- web/package.json | 2 +- web/src/api/client.ts | 7 + web/src/api/generated/schema.d.ts | 98 +++- web/src/app/layout-header-style.test.ts | 2 +- web/src/app/layout-header-style.ts | 4 +- web/src/app/layout-main-content.test.ts | 2 +- web/src/app/layout-main-content.ts | 8 +- web/src/app/layout.tsx | 208 ++++--- web/src/app/router.tsx | 62 +- web/src/features/report/use-skill-reports.ts | 10 +- .../review/review-skill-detail-section.tsx | 2 +- web/src/features/search/search-bar.tsx | 6 +- web/src/features/skill/skill-card.tsx | 22 +- .../features/token/create-token-dialog.tsx | 12 +- web/src/i18n/locales/en.json | 42 +- web/src/i18n/locales/ru.json | 42 +- web/src/i18n/locales/zh.json | 46 +- web/src/index.css | 365 +++++++++--- web/src/pages/dashboard.test.tsx | 13 +- web/src/pages/dashboard.tsx | 344 ++++++------ web/src/pages/dashboard/my-namespaces.test.ts | 4 + web/src/pages/dashboard/my-namespaces.tsx | 53 +- web/src/pages/dashboard/my-skills.tsx | 11 +- web/src/pages/dashboard/publish.tsx | 40 +- web/src/pages/dashboard/reports.test.ts | 2 +- web/src/pages/dashboard/reports.tsx | 24 +- web/src/pages/dashboard/review-progress.tsx | 1 + web/src/pages/home.tsx | 6 +- web/src/pages/landing.test.tsx | 9 +- web/src/pages/landing.tsx | 530 +++++++++++++----- .../pages/settings/notification-settings.tsx | 6 +- web/src/pages/settings/profile.tsx | 6 +- web/src/pages/settings/security.tsx | 4 +- web/src/shared/components/brand-mark.tsx | 23 + .../components/dashboard-page-header.tsx | 21 +- .../shared/components/landing-quick-start.tsx | 482 +++++++++++----- .../shared/components/language-switcher.tsx | 14 +- web/src/shared/components/namespace-badge.tsx | 6 +- web/src/shared/components/theme-toggle.tsx | 16 +- web/src/shared/components/user-menu.test.tsx | 27 +- web/src/shared/components/user-menu.tsx | 63 +-- web/src/shared/hooks/use-namespace-queries.ts | 27 + web/src/shared/ui/button.test.ts | 18 +- web/src/shared/ui/button.tsx | 27 +- web/src/shared/ui/card.tsx | 14 +- web/src/shared/ui/dialog.tsx | 12 +- web/src/shared/ui/input.tsx | 4 +- web/src/shared/ui/select.test.ts | 12 +- web/src/shared/ui/select.tsx | 67 ++- web/tailwind.config.ts | 4 +- 57 files changed, 1975 insertions(+), 922 deletions(-) create mode 100644 web/src/shared/components/brand-mark.tsx diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java index 3bd04be2..60e1b080 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java @@ -76,6 +76,15 @@ public class NamespaceController extends BaseApiController { namespacePortalQueryAppService.listMyNamespaces(userNsRoles, platformRoles(principal))); } + @GetMapping("/me/namespaces/page") + public ApiResponse> listMyNamespacesPage( + Pageable pageable, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @AuthenticationPrincipal PlatformPrincipal principal) { + return ok("response.success.read", + namespacePortalQueryAppService.listMyNamespacesPage(pageable, userNsRoles, platformRoles(principal))); + } + @GetMapping("/namespaces/{slug}") public ApiResponse getNamespace(@PathVariable String slug, @RequestAttribute("userId") String userId, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java index 7946c943..43c506fb 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java @@ -103,6 +103,26 @@ public class NamespacePortalQueryAppService { .toList(); } + @Transactional(readOnly = true) + public PageResponse listMyNamespacesPage(Pageable pageable, + Map userNamespaceRoles, + Set platformRoles) { + Map namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of(); + if (namespaceRoles.isEmpty()) { + Page empty = new PageImpl<>(List.of(), pageable, 0); + return PageResponse.from(empty); + } + + Pageable sortedPageable = pageable.isPaged() + ? PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), + org.springframework.data.domain.Sort.by("slug").ascending()) + : PageRequest.of(0, 10, org.springframework.data.domain.Sort.by("slug").ascending()); + Page page = namespaceRepository + .findByIdIn(namespaceRoles.keySet().stream().toList(), sortedPageable) + .map(namespace -> toMyNamespaceResponse(namespace, namespaceRoles.get(namespace.getId()))); + return PageResponse.from(page); + } + @Transactional(readOnly = true) public NamespaceResponse getNamespace(String slug, String userId, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java index d6353a7f..d7dc9142 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java @@ -21,6 +21,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.PageRequest; import org.springframework.http.MediaType; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; @@ -95,6 +96,26 @@ class NamespacePortalControllerTest { .andExpect(jsonPath("$.data[0].canDelete").value(false)); } + @Test + void listMyNamespacesPage_limitsResultsAndReturnsTotal() throws Exception { + Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM); + given(namespaceRepository.findByIdIn(eq(List.of(1L)), any(org.springframework.data.domain.Pageable.class))) + .willReturn(new org.springframework.data.domain.PageImpl<>(List.of(namespace), PageRequest.of(0, 10), 1)); + given(namespaceMemberRepository.findByUserId("owner-1")) + .willReturn(List.of(new NamespaceMember(1L, "owner-1", NamespaceRole.OWNER))); + + mockMvc.perform(get("/api/v1/me/namespaces/page") + .param("page", "0") + .param("size", "10") + .with(auth("owner-1")) + .requestAttr("userId", "owner-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.items[0].slug").value("team-a")) + .andExpect(jsonPath("$.data.total").value(1)) + .andExpect(jsonPath("$.data.size").value(10)); + } + @Test void listMyNamespaces_doesNotTreatSuperAdminOverrideAsNamespaceMembership() throws Exception { given(namespaceMemberRepository.findByUserId("super-1")).willReturn(List.of()); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java index 2cf39c13..58e32532 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java @@ -13,6 +13,7 @@ public interface NamespaceRepository { Optional findById(Long id); List findAll(); List findByIdIn(List ids); + Page findByIdIn(List ids, Pageable pageable); Optional findBySlug(String slug); Page findByStatus(NamespaceStatus status, Pageable pageable); Namespace save(Namespace namespace); diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java index 7e7f3db0..e71835a7 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java @@ -18,6 +18,7 @@ import java.util.Optional; public interface NamespaceJpaRepository extends JpaRepository, NamespaceRepository { List findByIdIn(List ids); + Page findByIdIn(List ids, Pageable pageable); Optional findBySlug(String slug); Page findByStatus(NamespaceStatus status, Pageable pageable); } diff --git a/web/e2e/promotions-review.spec.ts b/web/e2e/promotions-review.spec.ts index 400ab72a..ebbacf77 100644 --- a/web/e2e/promotions-review.spec.ts +++ b/web/e2e/promotions-review.spec.ts @@ -74,14 +74,14 @@ test.describe('Promotion review dashboard', () => { }), }) }) - await page.route('**/api/web/me/namespaces', async (route) => { + await page.route('**/api/web/me/namespaces/page?*', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 0, msg: 'success', - data: [], + data: { items: [], total: 0, page: 0, size: 10 }, timestamp: new Date().toISOString(), requestId: 'e2e-namespaces', }), diff --git a/web/e2e/theme-toggle.spec.ts b/web/e2e/theme-toggle.spec.ts index e01105c8..84628c7f 100644 --- a/web/e2e/theme-toggle.spec.ts +++ b/web/e2e/theme-toggle.spec.ts @@ -25,19 +25,26 @@ test.describe('Light and dark theme', () => { }), }) }) - await page.route('**/api/web/me/namespaces', async (route) => { + await page.route('**/api/web/me/namespaces/page?*', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 0, msg: 'success', - data: [], + data: { items: [], total: 0, page: 0, size: 10 }, timestamp: '2026-09-01T00:00:00Z', requestId: 'theme-namespace-fixture', }), }) }) + await page.route('**/api/web/me/namespaces', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ code: 0, msg: 'success', data: [], timestamp: '2026-09-01T00:00:00Z', requestId: 'theme-namespace-legacy-fixture' }), + }) + }) await page.route('**/api/web/notifications/unread-count', async (route) => { await route.fulfill({ status: 200, diff --git a/web/package.json b/web/package.json index c1c0f5f1..bb6ef7ee 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "skillhub-web", "private": true, - "version": "0.1.0", + "version": "0.1.13", "type": "module", "packageManager": "pnpm@10.33.0", "pnpm": { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index c503257f..ceadcccd 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -660,6 +660,13 @@ export const namespaceApi = { return fetchJson(`${WEB_API_PREFIX}/me/namespaces`) }, + async listMinePage(params?: { page?: number; size?: number }): Promise> { + const searchParams = new URLSearchParams() + searchParams.set('page', String(params?.page ?? 0)) + searchParams.set('size', String(params?.size ?? 10)) + return fetchJson>(`${WEB_API_PREFIX}/me/namespaces/page?${searchParams.toString()}`) + }, + async getDetail(slug: string): Promise { return fetchJson(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}`) }, diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 5fc821ed..d6c0271a 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -2964,6 +2964,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/me/namespaces/page": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listMyNamespacesPage"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/me/namespaces/page": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listMyNamespacesPage_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/me/namespaces": { parameters: { query?: never; @@ -5093,11 +5125,11 @@ export interface components { /** Format: int32 */ size?: number; }; - ApiResponseListMyNamespaceResponse: { + ApiResponsePageResponseMyNamespaceResponse: { /** Format: int32 */ code?: number; msg?: string; - data?: components["schemas"]["MyNamespaceResponse"][]; + data?: components["schemas"]["PageResponseMyNamespaceResponse"]; /** Format: date-time */ timestamp?: string; requestId?: string; @@ -5127,6 +5159,24 @@ export interface components { canRestore?: boolean; canDelete?: boolean; }; + PageResponseMyNamespaceResponse: { + items?: components["schemas"]["MyNamespaceResponse"][]; + /** Format: int64 */ + total?: number; + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + }; + ApiResponseListMyNamespaceResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["MyNamespaceResponse"][]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; ApiResponseGovernanceSummaryResponse: { /** Format: int32 */ code?: number; @@ -11240,6 +11290,50 @@ export interface operations { }; }; }; + listMyNamespacesPage: { + parameters: { + query: { + pageable: components["schemas"]["Pageable"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseMyNamespaceResponse"]; + }; + }; + }; + }; + listMyNamespacesPage_1: { + parameters: { + query: { + pageable: components["schemas"]["Pageable"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseMyNamespaceResponse"]; + }; + }; + }; + }; listMyNamespaces: { parameters: { query?: never; diff --git a/web/src/app/layout-header-style.test.ts b/web/src/app/layout-header-style.test.ts index 680aa658..11ed5c92 100644 --- a/web/src/app/layout-header-style.test.ts +++ b/web/src/app/layout-header-style.test.ts @@ -6,7 +6,7 @@ describe('getAppHeaderClassName', () => { const className = getAppHeaderClassName(false) expect(className).not.toContain(APP_HEADER_ELEVATED_CLASS_NAME) - expect(className).toContain('bg-background/90') + expect(className).toContain('bg-background/70') expect(className).not.toContain('bg-white') }) diff --git a/web/src/app/layout-header-style.ts b/web/src/app/layout-header-style.ts index 49288285..4c1d420b 100644 --- a/web/src/app/layout-header-style.ts +++ b/web/src/app/layout-header-style.ts @@ -1,10 +1,10 @@ import { cn } from '@/shared/lib/utils' export const APP_HEADER_BASE_CLASS_NAME = - 'sticky top-0 z-50 flex items-center justify-between border-b border-border/70 bg-background/90 px-4 py-4 backdrop-blur-xl transition-[background-color,border-color,box-shadow] duration-200 supports-[backdrop-filter]:bg-background/80 sm:px-6 md:px-12' + 'sticky top-0 z-50 flex items-center justify-between border-b border-border/30 bg-background/70 px-4 py-2.5 backdrop-blur-xl transition-[background-color,border-color,box-shadow] duration-150 supports-[backdrop-filter]:bg-background/60 sm:px-6 md:px-12 min-h-[52px]' export const APP_HEADER_ELEVATED_CLASS_NAME = - 'shadow-[0_12px_30px_-24px_hsl(var(--foreground)/0.45)]' + 'border-b border-border/30 shadow-[0_1px_2px_0_rgb(0_0_0/0.04)]' export function getAppHeaderClassName(isElevated: boolean): string { return cn(APP_HEADER_BASE_CLASS_NAME, isElevated && APP_HEADER_ELEVATED_CLASS_NAME) diff --git a/web/src/app/layout-main-content.test.ts b/web/src/app/layout-main-content.test.ts index 1eb979a0..3a0e6f8b 100644 --- a/web/src/app/layout-main-content.test.ts +++ b/web/src/app/layout-main-content.test.ts @@ -33,7 +33,7 @@ describe('getAppMainContentLayout', () => { mainClassName: CENTERED_MAIN_CLASS_NAME, contentClassName: CENTERED_DASHBOARD_CONTENT_CLASS_NAME, }) - expect(layout.contentClassName).toContain('max-w-[1200px]') + expect(layout.contentClassName).toContain('max-w-[1100px]') }) it('leaves other non-landing routes on the default full-width app content layout', () => { diff --git a/web/src/app/layout-main-content.ts b/web/src/app/layout-main-content.ts index 28dedc11..ae1689af 100644 --- a/web/src/app/layout-main-content.ts +++ b/web/src/app/layout-main-content.ts @@ -1,8 +1,10 @@ export const LANDING_MAIN_CLASS_NAME = 'flex-1 relative z-10' export const DEFAULT_MAIN_CLASS_NAME = 'flex-1 relative z-10 px-6 py-10 md:px-12' -export const CENTERED_MAIN_CLASS_NAME = 'flex-1 relative z-10 px-4 py-8 sm:px-6 md:px-8 md:py-10 lg:px-10 xl:px-14 2xl:px-20' +export const CENTERED_MAIN_CLASS_NAME = 'flex-1 relative z-10 px-4 py-8 sm:px-6 md:px-12 md:py-10' export const CENTERED_SEARCH_CONTENT_CLASS_NAME = 'mx-auto w-full max-w-[1200px]' -export const CENTERED_DASHBOARD_CONTENT_CLASS_NAME = 'mx-auto w-full max-w-[1200px]' +export const CENTERED_DASHBOARD_CONTENT_CLASS_NAME = 'mx-auto w-full max-w-[1100px]' + +export const DASHBOARD_PATH_PREFIXES = ['/dashboard', '/settings/'] as const interface AppMainContentLayout { mainClassName: string @@ -31,7 +33,7 @@ export function getAppMainContentLayout(pathname: string): AppMainContentLayout } } - if (pathname === '/dashboard' || pathname.startsWith('/dashboard/')) { + if (pathname === '/dashboard' || pathname.startsWith('/dashboard/') || pathname.startsWith('/settings/')) { return { mainClassName: CENTERED_MAIN_CLASS_NAME, contentClassName: CENTERED_DASHBOARD_CONTENT_CLASS_NAME, diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 7d5000a8..b0079f7a 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -1,16 +1,22 @@ import { Suspense, useEffect, useRef, useState } from 'react' import { Outlet, Link, useRouterState } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' +import { Menu, X } from 'lucide-react' import { useAuth } from '@/features/auth/use-auth' +import { BrandMark } from '@/shared/components/brand-mark' import { LanguageSwitcher } from '@/shared/components/language-switcher' import { ThemeToggle } from '@/shared/components/theme-toggle' import { UserMenu } from '@/shared/components/user-menu' import { NotificationBell } from '@/features/notification/notification-bell' import { dismissOpenOverlays } from '@/shared/lib/dismiss-open-overlays' import { syncDocumentLanguage } from '@/shared/lib/document-language' +import { DashboardSidebar, SIDEBAR_GROUPS } from '@/pages/dashboard' +import { canViewGovernanceCenter } from '@/shared/lib/governance-access' import { getAppHeaderClassName } from './layout-header-style' import { getAppMainContentLayout, resolveAppMainContentPathname } from './layout-main-content' +const FOOTER_LINK_CLASS_NAME = 'group relative inline-flex py-0.5 transition-colors duration-150 hover:text-foreground focus-visible:outline-none focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-secondary after:absolute after:inset-x-0 after:-bottom-0.5 after:h-px after:origin-left after:scale-x-0 after:bg-foreground/60 after:transition-transform after:duration-200 hover:after:scale-x-100 motion-reduce:after:transition-none' + /** * Application shell shared by all routed pages. * @@ -27,9 +33,19 @@ export function Layout() { }) const { user, isLoading } = useAuth() const [isHeaderElevated, setIsHeaderElevated] = useState(false) + const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const previousPathnameRef = useRef(pathname) const contentLayoutPathname = resolveAppMainContentPathname(pathname, resolvedPathname) const mainContentLayout = getAppMainContentLayout(contentLayoutPathname) + const isDashboardSubRoute = pathname !== '/dashboard' && pathname.startsWith('/dashboard') + const showSidebar = (isDashboardSubRoute && pathname !== '/dashboard/publish') || pathname.startsWith('/settings/') + const governanceVisible = canViewGovernanceCenter(user?.platformRoles) + const filteredDashboardGroups = SIDEBAR_GROUPS + .map((group) => ({ + ...group, + items: group.items.filter((item) => !item.admin || governanceVisible), + })) + .filter((group) => group.items.length > 0) useEffect(() => { syncDocumentLanguage(i18n.resolvedLanguage ?? i18n.language) @@ -72,6 +88,10 @@ export function Layout() { const isActive = (to: string, exact?: boolean) => { if (exact) return pathname === to + // 「控制台」按钮:仅在 /dashboard 主页或 /settings/* 时高亮 + if (to === '/dashboard') { + return pathname === '/dashboard' || pathname.startsWith('/settings/') + } // Keep matching strict so parent dashboard paths do not highlight unrelated child links. return pathname === to } @@ -91,11 +111,12 @@ export function Layout() { {/* Header */}
- + SkillHub -
+ {/* Mobile nav dropdown */} + {mobileMenuOpen ? ( +
+ +
+ ) : null} + {/* Main content */}
-
-
-
+
+
+
+
} >
+ {showSidebar ? ( +
+ +
+ +
+
+ ) : ( -
+ )} +
{/* Footer */} -