Compare commits

...

4 Commits

Author SHA1 Message Date
Germán Jabloñski
ecd934543e move to next package instead of using view config 2024-11-17 11:10:25 -03:00
Germán Jabloñski
a4dd0560b9 first version of plugin store 2024-11-17 09:22:34 -03:00
Germán Jabloñski
15bab6d31e Merge remote-tracking branch 'origin/beta' into plugin-store 2024-11-16 15:10:14 -03:00
Germán Jabloñski
c22e8535dc first commit 2024-11-11 00:44:12 -03:00
23 changed files with 827 additions and 5 deletions

View File

@@ -91,6 +91,7 @@
"graphql-http": "^1.22.0",
"graphql-playground-html": "1.6.30",
"http-status": "1.6.2",
"lucide-react": "^0.460.0",
"path-to-regexp": "^6.2.1",
"qs-esm": "7.0.2",
"react-diff-viewer-continued": "3.2.6",

View File

@@ -27,6 +27,9 @@ export const DefaultNavClient: React.FC<{
const { i18n } = useTranslation()
const { navOpen } = useNav()
const Link = (LinkWithDefault.default || LinkWithDefault) as typeof LinkWithDefault.default
const LinkElement = Link || 'a'
return (
<Fragment>
{groups.map(({ entities, label }, key) => {
@@ -46,10 +49,6 @@ export const DefaultNavClient: React.FC<{
id = `nav-global-${slug}`
}
const Link = (LinkWithDefault.default ||
LinkWithDefault) as typeof LinkWithDefault.default
const LinkElement = Link || 'a'
const activeCollection =
pathname.startsWith(href) && ['/', undefined].includes(pathname[href.length])
@@ -72,6 +71,11 @@ export const DefaultNavClient: React.FC<{
</NavGroup>
)
})}
<div className="nav-group Plugins" id="nav-group-Plugins">
<LinkElement href={`/admin/plugins`}>
<div className="nav-group__label">Plugins</div>
</LinkElement>
</div>
</Fragment>
)
}

View File

@@ -0,0 +1,58 @@
import { Download } from 'lucide-react'
import React from 'react'
import type { Package } from './usePackages.js'
import { Rating } from './Rating.js'
interface PackageCardProps {
pkg: Package
view: 'grid' | 'list'
}
export const PackageCard: React.FC<PackageCardProps> = ({ pkg, view }) => {
const formatDownloads = (num: number) => {
if (num >= 1000000) {
return `${(num / 1000000).toFixed(1)}M`
}
if (num >= 1000) {
return `${(num / 1000).toFixed(1)}K`
}
return num.toString()
}
const cardClassName = `package-card package-card--${view}`
return (
<div className={cardClassName}>
<div className="package-card__content">
<div className="package-card__info">
<h3 className="package-card__title">{pkg.name}</h3>
<p
className={`package-card__description ${
view === 'grid' ? 'package-card__description--grid' : ''
}`}
>
{pkg.description}
</p>
<div className="package-card__meta">
<Rating value={pkg.rating} />
<div className="package-card__downloads">
<Download className="package-card__downloads-icon" />
{formatDownloads(pkg.downloads)}
</div>
<span>v{pkg.version}</span>
</div>
</div>
<button
className={`package-card__button ${
pkg.isInstalled ? 'package-card__button--installed' : ''
}`}
type="button"
>
{pkg.isInstalled ? 'Installed' : 'Install'}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,21 @@
import { Star } from 'lucide-react'
import React from 'react'
interface RatingProps {
value: number
}
export const Rating: React.FC<RatingProps> = ({ value }) => {
return (
<div className="rating">
{[1, 2, 3, 4, 5].map((star) => (
<Star
className={`rating__star ${
star <= value ? 'rating__star--filled' : 'rating__star--empty'
}`}
key={star}
/>
))}
</div>
)
}

View File

@@ -0,0 +1,22 @@
import { Search } from 'lucide-react'
import React from 'react'
interface SearchBarProps {
onChange: (value: string) => void
value: string
}
export const SearchBar: React.FC<SearchBarProps> = ({ onChange, value }) => {
return (
<div className="search-bar">
<Search className="search-bar__icon" />
<input
className="search-bar__input"
onChange={(e) => onChange(e.target.value)}
placeholder="Search extensions..."
type="text"
value={value}
/>
</div>
)
}

View File

@@ -0,0 +1,35 @@
import React from 'react'
import type { TabMode } from '../types'
interface TabSwitcherProps {
activeTab: TabMode
installedCount: number
onTabChange: (tab: TabMode) => void
}
export const TabSwitcher: React.FC<TabSwitcherProps> = ({
activeTab,
installedCount,
onTabChange,
}) => {
return (
<div className="tab-switcher">
<button
className={`tab-switcher__tab ${activeTab === 'all' ? 'tab-switcher__tab--active' : ''}`}
onClick={() => onTabChange('all')}
>
All
</button>
<button
className={`tab-switcher__tab ${
activeTab === 'installed' ? 'tab-switcher__tab--active' : ''
}`}
onClick={() => onTabChange('installed')}
>
Installed
<span className="tab-switcher__count">{installedCount}</span>
</button>
</div>
)
}

View File

@@ -0,0 +1,28 @@
import { LayoutGrid, List } from 'lucide-react'
import React from 'react'
import type { ViewMode } from '../types'
interface ViewToggleProps {
onViewChange: (view: ViewMode) => void
view: ViewMode
}
export const ViewToggle: React.FC<ViewToggleProps> = ({ onViewChange, view }) => {
return (
<div className="view-toggle">
<button
className={`view-toggle__button ${view === 'list' ? 'view-toggle__button--active' : ''}`}
onClick={() => onViewChange('list')}
>
<List className="view-toggle__icon" />
</button>
<button
className={`view-toggle__button ${view === 'grid' ? 'view-toggle__button--active' : ''}`}
onClick={() => onViewChange('grid')}
>
<LayoutGrid className="view-toggle__icon" />
</button>
</div>
)
}

View File

@@ -0,0 +1,94 @@
import { useEffect, useState } from 'react'
export interface Package {
description: string
downloads: number
isInstalled?: boolean
name: string
rating: number
version: string
}
// Mocked data for demonstration
const MOCK_PACKAGES: Package[] = [
{
name: '@payloadcms/plugin-cloud',
description: 'Official Payload Cloud plugin for PayloadCMS',
downloads: 25000,
isInstalled: true,
rating: 4.8,
version: '1.0.0',
},
{
name: '@payloadcms/plugin-seo',
description: 'SEO plugin for PayloadCMS with automatic meta tags and sitemap generation',
downloads: 45000,
isInstalled: true,
rating: 4.9,
version: '2.1.0',
},
{
name: '@payloadcms/plugin-nested-docs',
description: 'Nested documents plugin for PayloadCMS',
downloads: 12000,
rating: 4.5,
version: '0.9.0',
},
{
name: '@payloadcms/plugin-form-builder',
description: 'Powerful form builder plugin for PayloadCMS',
downloads: 32000,
rating: 4.7,
version: '1.2.0',
},
{
name: 'payload-plugin-custom-fields',
description: 'Custom fields collection for PayloadCMS',
downloads: 8000,
rating: 4.2,
version: '0.5.0',
},
{
name: '@payloadcms/plugin-redirects',
description: 'URL redirects management for PayloadCMS',
downloads: 15000,
rating: 4.6,
version: '1.1.0',
},
]
export const usePackages = (searchTerm: string, activeTab: 'all' | 'installed') => {
const [packages, setPackages] = useState<Package[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
// Simulate API call
const fetchPackages = async () => {
setLoading(true)
await new Promise((resolve) => setTimeout(resolve, 800))
let filteredPackages = MOCK_PACKAGES
if (searchTerm) {
filteredPackages = filteredPackages.filter(
(pkg) =>
pkg.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
pkg.description.toLowerCase().includes(searchTerm.toLowerCase()),
)
}
if (activeTab === 'installed') {
filteredPackages = filteredPackages.filter((pkg) => pkg.isInstalled)
}
setPackages(filteredPackages)
setLoading(false)
}
fetchPackages().catch((error) => {
throw new Error(error)
})
}, [searchTerm, activeTab])
return { loading, packages }
}

View File

@@ -0,0 +1,72 @@
'use client'
import { PackageSearch } from 'lucide-react'
import React, { useState } from 'react'
import type { Package } from './components/usePackages.js'
import { PackageCard } from './components/PackageCard.js'
import { SearchBar } from './components/SearchBar.js'
import { TabSwitcher } from './components/TabSwitcher.js'
import { usePackages } from './components/usePackages.js'
import { ViewToggle } from './components/ViewToggle.js'
import './styles/main.scss'
type ViewMode = 'grid' | 'list'
type TabMode = 'all' | 'installed'
export const PluginsView: React.FC = () => {
const [searchTerm, setSearchTerm] = useState('')
const [view, setView] = useState<ViewMode>('list')
const [activeTab, setActiveTab] = useState<TabMode>('all')
const { loading, packages } = usePackages(searchTerm, activeTab)
const installedCount = packages.filter((pkg) => pkg.isInstalled).length
return (
<>
<div className="app">
<div className="container">
<header className="app__header">
<div className="app__header-title">
<PackageSearch className="app__header-title-icon" />
<h1 className="app__header-title-text">PayloadCMS Plugins</h1>
</div>
<ViewToggle onViewChange={setView} view={view} />
</header>
<div className="app__controls">
<SearchBar onChange={setSearchTerm} value={searchTerm} />
<TabSwitcher
activeTab={activeTab}
installedCount={installedCount}
onTabChange={setActiveTab}
/>
</div>
{loading ? (
<div className="loading">
{[1, 2, 3].map((i) => (
<div className="loading__item" key={i} />
))}
</div>
) : packages.length === 0 ? (
<div className="empty-state">
<PackageSearch className="empty-state__icon" />
<h3 className="empty-state__title">No packages found</h3>
<p className="empty-state__description">
Try adjusting your search or filters to find what you're looking for
</p>
</div>
) : (
<div className={`app__content app__content--${view}`}>
{packages.map((pkg: Package) => (
<PackageCard key={pkg.name} pkg={pkg} view={view} />
))}
</div>
)}
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,60 @@
@mixin flex($direction: row, $justify: flex-start, $align: stretch, $gap: 0) {
display: flex;
flex-direction: $direction;
justify-content: $justify;
align-items: $align;
@if $gap != 0 {
gap: $gap;
}
}
@mixin button-base {
padding: $spacing-2 $spacing-4;
border-radius: $radius-full;
transition: $transition-all;
cursor: pointer;
border: none;
font-size: 0.95rem;
line-height: 1.5;
text-align: center;
outline: none;
&:focus-visible {
box-shadow: 0 0 0 3px rgba($color-primary, 0.1);
}
}
@mixin button-primary {
@include button-base;
background-color: $color-primary;
color: $color-white;
box-shadow: $shadow-sm;
&:hover {
background-color: $color-primary-hover;
transform: translateY(-1px);
box-shadow: $shadow-md;
}
&:active {
transform: translateY(0);
}
}
@mixin button-secondary {
@include button-base;
background-color: $color-gray-100;
color: $color-gray-700;
&:hover {
background-color: $color-gray-200;
color: $color-gray-900;
}
}
@mixin card-base {
background-color: $color-white;
border-radius: $radius-xl;
border: 1px solid $color-gray-200;
overflow: hidden;
}

View File

@@ -0,0 +1,46 @@
// Colors
$color-primary: #000000;
$color-primary-hover: #272727;
$color-primary-light: rgba(0, 64, 255, 0.1);
$color-gray-50: #f8fafc;
$color-gray-100: #f1f5f9;
$color-gray-200: #e2e8f0;
$color-gray-300: #cbd5e1;
$color-gray-400: #94a3b8;
$color-gray-500: #64748b;
$color-gray-600: #475569;
$color-gray-700: #334155;
$color-gray-800: #1e293b;
$color-gray-900: #0f172a;
$color-white: #ffffff;
$color-yellow-400: #fbbf24;
// Shadows
$shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
$shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
$shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
// Spacing
$spacing-1: 0.25rem;
$spacing-2: 0.5rem;
$spacing-3: 0.75rem;
$spacing-4: 1rem;
$spacing-5: 1.25rem;
$spacing-6: 1.5rem;
$spacing-8: 2rem;
$spacing-12: 3rem;
// Border radius
$radius-md: 0.375rem;
$radius-lg: 0.5rem;
$radius-xl: 0.75rem;
$radius-full: 9999px;
// Transitions
$transition-all: all 0.2s ease-in-out;
// Breakpoints
$breakpoint-sm: 640px;
$breakpoint-md: 768px;
$breakpoint-lg: 1024px;
$breakpoint-xl: 1280px;

View File

@@ -0,0 +1,25 @@
@import '../variables';
@import '../mixins';
.empty-state {
text-align: center;
padding: $spacing-12 0;
&__icon {
width: 3rem;
height: 3rem;
color: $color-gray-400;
margin: 0 auto $spacing-4;
}
&__title {
font-size: 1.125rem;
font-weight: 500;
color: $color-gray-900;
margin-bottom: $spacing-2;
}
&__description {
color: $color-gray-600;
}
}

View File

@@ -0,0 +1,21 @@
@import '../variables';
.loading {
display: grid;
gap: $spacing-4;
&__item {
@include card-base;
height: 8rem;
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: .5;
}
}

View File

@@ -0,0 +1,92 @@
@import '../variables';
@import '../mixins';
.package-card {
@include card-base;
background-color: $color-white;
box-shadow: $shadow-sm;
transition: $transition-all;
&:hover {
transform: translateY(-2px);
box-shadow: $shadow-md;
}
&--list {
padding: $spacing-5;
.package-card__content {
@include flex($justify: space-between, $align: center, $gap: $spacing-6);
}
.package-card__info {
flex: 1;
}
.package-card__button {
min-width: 120px;
}
}
&--grid {
padding: $spacing-6;
height: 100%;
.package-card__content {
@include flex($direction: column, $justify: space-between);
height: 100%;
}
.package-card__button {
margin-top: $spacing-4;
}
}
&__title {
font-size: 1.125rem;
font-weight: 600;
color: $color-gray-900;
margin-bottom: $spacing-2;
}
&__description {
color: $color-gray-600;
font-size: 0.95rem;
line-height: 1.5;
margin-bottom: $spacing-4;
&--grid {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
&__meta {
@include flex($align: center, $gap: $spacing-4);
color: $color-gray-500;
font-size: 0.875rem;
}
&__downloads {
@include flex($align: center, $gap: $spacing-1);
color: $color-gray-600;
font-weight: 500;
}
&__button {
@include button-primary;
font-weight: 500;
height: 2.5rem;
&--installed {
background-color: $color-primary-light;
color: $color-primary;
&:hover {
background-color: darken($color-primary-light, 5%);
}
}
}
}

View File

@@ -0,0 +1,19 @@
@import '../variables';
.rating {
@include flex($align: center, $gap: $spacing-1);
&__star {
width: 1rem;
height: 1rem;
&--filled {
color: $color-yellow-400;
fill: $color-yellow-400;
}
&--empty {
color: $color-gray-300;
}
}
}

View File

@@ -0,0 +1,39 @@
@import '../variables';
@import '../mixins';
.search-bar {
position: relative;
width: 100%;
max-width: 36rem;
&__icon {
position: absolute;
left: $spacing-4;
top: 50%;
transform: translateY(-50%);
color: $color-gray-400;
width: 1.25rem;
height: 1.25rem;
}
&__input {
width: 100%;
padding: $spacing-3 $spacing-4 $spacing-3 $spacing-12;
border-radius: $radius-full;
background-color: $color-white;
border: 1px solid $color-gray-200;
font-size: 0.95rem;
outline: none;
transition: $transition-all;
box-shadow: $shadow-sm;
&:focus {
border-color: $color-primary;
box-shadow: 0 0 0 3px rgba($color-primary, 0.1);
}
&::placeholder {
color: $color-gray-400;
}
}
}

View File

@@ -0,0 +1,42 @@
@import '../variables';
@import '../mixins';
.tab-switcher {
@include flex($justify: center, $align: center, $gap: $spacing-1);
background-color: $color-gray-100;
padding: $spacing-1;
border-radius: $radius-full;
box-shadow: $shadow-sm;
&__tab {
@include button-base;
padding: $spacing-2 $spacing-4;
border-radius: $radius-full;
font-weight: 500;
&--active {
background-color: $color-white;
color: $color-primary;
box-shadow: $shadow-sm;
}
&:not(&--active) {
color: $color-gray-600;
&:hover {
color: $color-gray-900;
background-color: rgba($color-gray-200, 0.5);
}
}
}
&__count {
margin-left: $spacing-2;
background-color: $color-primary-light;
color: $color-primary;
padding: 0.125rem $spacing-2;
font-size: 0.75rem;
border-radius: $radius-full;
font-weight: 600;
}
}

View File

@@ -0,0 +1,36 @@
@import '../variables';
@import '../mixins';
.view-toggle {
@include flex($justify: center, $align: center);
background-color: $color-gray-100;
padding: $spacing-1;
border-radius: $radius-full;
box-shadow: $shadow-sm;
&__button {
@include button-base;
padding: $spacing-2 $spacing-3;
border-radius: $radius-full;
&--active {
background-color: $color-white;
color: $color-primary;
box-shadow: $shadow-sm;
}
&:not(&--active) {
color: $color-gray-600;
&:hover {
color: $color-gray-900;
background-color: rgba($color-gray-200, 0.5);
}
}
}
&__icon {
width: 1.25rem;
height: 1.25rem;
}
}

View File

@@ -0,0 +1,88 @@
@import 'variables';
@import 'mixins';
// Layout
.container {
max-width: 80rem;
margin: 0 auto;
padding: 0 $spacing-4;
@media (min-width: $breakpoint-sm) {
padding: 0 $spacing-6;
}
@media (min-width: $breakpoint-lg) {
padding: 0 $spacing-8;
}
}
.app {
min-height: 100vh;
padding-bottom: $spacing-12;
&__header {
@include flex($justify: space-between, $align: center);
margin-bottom: $spacing-8;
padding-top: $spacing-8;
&-title {
@include flex($align: center, $gap: $spacing-3);
&-icon {
width: 2rem;
height: 2rem;
color: $color-primary;
}
&-text {
font-size: 1.75rem;
font-weight: 700;
background: linear-gradient(135deg, $color-primary 0%, darken($color-primary, 15%) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
}
}
&__controls {
@include flex($direction: column, $gap: $spacing-4);
margin-bottom: $spacing-8;
@media (min-width: $breakpoint-sm) {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
}
&__content {
&--grid {
display: grid;
grid-template-columns: 1fr;
gap: $spacing-6;
@media (min-width: $breakpoint-sm) {
grid-template-columns: repeat(2, 1fr);
}
@media (min-width: $breakpoint-lg) {
grid-template-columns: repeat(3, 1fr);
}
}
&--list {
display: flex;
flex-direction: column;
gap: $spacing-4;
}
}
}
// Import component styles
@import 'components/search-bar';
@import 'components/view-toggle';
@import 'components/tab-switcher';
@import 'components/rating';
@import 'components/package-card';
@import 'components/empty-state';
@import 'components/loading';

View File

@@ -20,6 +20,7 @@ import { forgotPasswordBaseClass, ForgotPasswordView } from '../ForgotPassword/i
import { ListView } from '../List/index.js'
import { loginBaseClass, LoginView } from '../Login/index.js'
import { LogoutInactivity, LogoutView } from '../Logout/index.js'
import { PluginsView } from '../Plugins/index.js'
import { ResetPassword, resetPasswordBaseClass } from '../ResetPassword/index.js'
import { UnauthorizedView } from '../Unauthorized/index.js'
import { Verify, verifyBaseClass } from '../Verify/index.js'
@@ -30,6 +31,7 @@ const baseClasses = {
account: 'account',
forgot: forgotPasswordBaseClass,
login: loginBaseClass,
plugins: 'plugins',
reset: resetPasswordBaseClass,
verify: verifyBaseClass,
}
@@ -50,6 +52,7 @@ const oneSegmentViews: OneSegmentViews = {
inactivity: LogoutInactivity,
login: LoginView,
logout: LogoutView,
plugins: PluginsView,
unauthorized: UnauthorizedView,
}
@@ -165,6 +168,7 @@ export const getViewFromConfig = ({
// --> /logout
// --> /logout-inactivity
// --> /unauthorized
// --> /plugins
ViewToRender = {
Component: oneSegmentViews[viewKey],
@@ -173,7 +177,7 @@ export const getViewFromConfig = ({
templateClassName = baseClasses[viewKey]
templateType = 'minimal'
if (viewKey === 'account') {
if (viewKey === 'account' || viewKey === 'plugins') {
templateType = 'default'
}
}

View File

@@ -25,6 +25,7 @@ export const defaults: Omit<Config, 'db' | 'editor' | 'secret'> = {
inactivity: '/logout-inactivity',
login: '/login',
logout: '/logout',
plugins: '/plugins',
reset: '/reset',
unauthorized: '/unauthorized',
},

View File

@@ -826,6 +826,8 @@ export type Config = {
login?: string
/** The route for the logout page. */
logout?: string
/** The route for the plugins page. */
plugins?: string
/** The route for the reset password page. */
reset?: string
/** The route for the unauthorized page. */

12
pnpm-lock.yaml generated
View File

@@ -718,6 +718,9 @@ importers:
http-status:
specifier: 1.6.2
version: 1.6.2
lucide-react:
specifier: ^0.460.0
version: 0.460.0(react@19.0.0-rc-65a56d0e-20241020)
next:
specifier: ^15.0.0
version: 15.0.0(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.48.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@0.0.0-experimental-24ec0eb-20240918)(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)(sass@1.77.4)
@@ -7615,6 +7618,11 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lucide-react@0.460.0:
resolution: {integrity: sha512-BVtq/DykVeIvRTJvRAgCsOwaGL8Un3Bxh8MbDxMhEWlZay3T4IpEKDEpwt5KZ0KJMHzgm6jrltxlT5eXOWXDHg==}
peerDependencies:
react: 19.0.0-rc-65a56d0e-20241020
magic-string@0.30.12:
resolution: {integrity: sha512-Ea8I3sQMVXr8JhN4z+H/d8zwo+tYDgHE9+5G4Wnrwhs0gaK9fXTKx0Tw5Xwsd/bCPTTZNRAdpyzvoeORe9LYpw==}
@@ -17338,6 +17346,10 @@ snapshots:
dependencies:
yallist: 3.1.1
lucide-react@0.460.0(react@19.0.0-rc-65a56d0e-20241020):
dependencies:
react: 19.0.0-rc-65a56d0e-20241020
magic-string@0.30.12:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.0