mirror of
https://github.com/xtr-dev/payload-feature-flags.git
synced 2025-12-10 02:43:25 +00:00
Integrate Payload CMS theme system and add clickable flag names
Theme Integration:
- Added useTheme hook from @payloadcms/ui
- Replaced all hardcoded colors with CSS custom properties
- Created getThemeStyles() function for consistent theming
- Updated all UI elements to respect dark/light theme settings
- Added proper contrast for text, backgrounds, and borders
Navigation Enhancement:
- Made feature flag names clickable links
- Links navigate to /admin/collections/feature-flags/{id} for editing
- Added hover effects with underline on flag name links
- Used theme-aware link color (info blue)
The interface now properly adapts to Payload's admin panel theme,
supporting both dark and light modes seamlessly.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import { useState, useEffect, useCallback, useMemo, memo } from 'react'
|
import { useState, useEffect, useCallback, useMemo, memo } from 'react'
|
||||||
import { useConfig } from '@payloadcms/ui'
|
import { useConfig, useTheme } from '@payloadcms/ui'
|
||||||
|
|
||||||
interface FeatureFlag {
|
interface FeatureFlag {
|
||||||
id: string
|
id: string
|
||||||
@@ -22,6 +22,7 @@ interface FeatureFlag {
|
|||||||
|
|
||||||
const FeatureFlagsViewComponent = () => {
|
const FeatureFlagsViewComponent = () => {
|
||||||
const { config } = useConfig()
|
const { config } = useConfig()
|
||||||
|
const { theme } = useTheme()
|
||||||
const [flags, setFlags] = useState<FeatureFlag[]>([])
|
const [flags, setFlags] = useState<FeatureFlag[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
@@ -168,27 +169,47 @@ const FeatureFlagsViewComponent = () => {
|
|||||||
return <span>{sortDirection === 'asc' ? '↑' : '↓'}</span>
|
return <span>{sortDirection === 'asc' ? '↑' : '↓'}</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Theme-aware styles
|
||||||
|
const getThemeStyles = () => ({
|
||||||
|
background: 'var(--theme-bg)',
|
||||||
|
surface: 'var(--theme-elevation-50)',
|
||||||
|
surfaceHover: 'var(--theme-elevation-100)',
|
||||||
|
border: 'var(--theme-elevation-150)',
|
||||||
|
text: 'var(--theme-text)',
|
||||||
|
textMuted: 'var(--theme-text-400)',
|
||||||
|
textSubdued: 'var(--theme-text-600)',
|
||||||
|
primary: 'var(--theme-success-500)',
|
||||||
|
warning: 'var(--theme-warning-500)',
|
||||||
|
error: 'var(--theme-error-500)',
|
||||||
|
info: 'var(--theme-info-500)',
|
||||||
|
inputBg: 'var(--theme-elevation-0)',
|
||||||
|
inputBorder: 'var(--theme-elevation-250)',
|
||||||
|
headerBg: 'var(--theme-elevation-100)',
|
||||||
|
})
|
||||||
|
|
||||||
|
const styles = getThemeStyles()
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
<div style={{ padding: '2rem', textAlign: 'center', backgroundColor: styles.background, color: styles.text }}>
|
||||||
<div style={{ fontSize: '1.125rem', color: '#6b7280' }}>Loading feature flags...</div>
|
<div style={{ fontSize: '1.125rem', color: styles.textMuted }}>Loading feature flags...</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '2rem', maxWidth: '100%' }}>
|
<div style={{ padding: '2rem', maxWidth: '100%', backgroundColor: styles.background, color: styles.text, minHeight: '100vh' }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div style={{ marginBottom: '2rem' }}>
|
<div style={{ marginBottom: '2rem' }}>
|
||||||
<h1 style={{
|
<h1 style={{
|
||||||
fontSize: '2rem',
|
fontSize: '2rem',
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
color: '#111827',
|
color: styles.text,
|
||||||
marginBottom: '0.5rem'
|
marginBottom: '0.5rem'
|
||||||
}}>
|
}}>
|
||||||
Feature Flags Dashboard
|
Feature Flags Dashboard
|
||||||
</h1>
|
</h1>
|
||||||
<p style={{ color: '#6b7280', fontSize: '1rem' }}>
|
<p style={{ color: styles.textMuted, fontSize: '1rem' }}>
|
||||||
Manage all feature flags in a spreadsheet view
|
Manage all feature flags in a spreadsheet view
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -199,7 +220,7 @@ const FeatureFlagsViewComponent = () => {
|
|||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
top: '20px',
|
top: '20px',
|
||||||
right: '20px',
|
right: '20px',
|
||||||
backgroundColor: '#10b981',
|
backgroundColor: styles.primary,
|
||||||
color: 'white',
|
color: 'white',
|
||||||
padding: '0.75rem 1.5rem',
|
padding: '0.75rem 1.5rem',
|
||||||
borderRadius: '0.5rem',
|
borderRadius: '0.5rem',
|
||||||
@@ -213,11 +234,11 @@ const FeatureFlagsViewComponent = () => {
|
|||||||
{error && (
|
{error && (
|
||||||
<div style={{
|
<div style={{
|
||||||
marginBottom: '1rem',
|
marginBottom: '1rem',
|
||||||
backgroundColor: '#fef2f2',
|
backgroundColor: styles.error + '20',
|
||||||
border: '1px solid #fecaca',
|
border: `1px solid ${styles.error}`,
|
||||||
borderRadius: '0.5rem',
|
borderRadius: '0.5rem',
|
||||||
padding: '1rem',
|
padding: '1rem',
|
||||||
color: '#dc2626'
|
color: styles.error
|
||||||
}}>
|
}}>
|
||||||
<strong>Error:</strong> {error}
|
<strong>Error:</strong> {error}
|
||||||
</div>
|
</div>
|
||||||
@@ -238,25 +259,27 @@ const FeatureFlagsViewComponent = () => {
|
|||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
style={{
|
style={{
|
||||||
padding: '0.5rem 1rem',
|
padding: '0.5rem 1rem',
|
||||||
border: '1px solid #d1d5db',
|
border: `1px solid ${styles.inputBorder}`,
|
||||||
borderRadius: '0.5rem',
|
borderRadius: '0.5rem',
|
||||||
fontSize: '0.875rem',
|
fontSize: '0.875rem',
|
||||||
width: '300px'
|
width: '300px',
|
||||||
|
backgroundColor: styles.inputBg,
|
||||||
|
color: styles.text
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
|
||||||
<div style={{ fontSize: '0.875rem', color: '#6b7280' }}>
|
<div style={{ fontSize: '0.875rem', color: styles.textMuted }}>
|
||||||
{filteredAndSortedFlags.length} of {flags.filter(f => f && f.name).length} flags
|
{filteredAndSortedFlags.length} of {flags.filter(f => f && f.name).length} flags
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => fetchFlags()}
|
onClick={() => fetchFlags()}
|
||||||
style={{
|
style={{
|
||||||
padding: '0.5rem 1rem',
|
padding: '0.5rem 1rem',
|
||||||
border: '1px solid #d1d5db',
|
border: `1px solid ${styles.inputBorder}`,
|
||||||
borderRadius: '0.5rem',
|
borderRadius: '0.5rem',
|
||||||
backgroundColor: 'white',
|
backgroundColor: styles.surface,
|
||||||
color: '#374151',
|
color: styles.text,
|
||||||
fontSize: '0.875rem',
|
fontSize: '0.875rem',
|
||||||
cursor: 'pointer'
|
cursor: 'pointer'
|
||||||
}}
|
}}
|
||||||
@@ -268,8 +291,8 @@ const FeatureFlagsViewComponent = () => {
|
|||||||
|
|
||||||
{/* Spreadsheet Table */}
|
{/* Spreadsheet Table */}
|
||||||
<div style={{
|
<div style={{
|
||||||
backgroundColor: 'white',
|
backgroundColor: styles.surface,
|
||||||
border: '1px solid #e5e7eb',
|
border: `1px solid ${styles.border}`,
|
||||||
borderRadius: '0.75rem',
|
borderRadius: '0.75rem',
|
||||||
overflow: 'hidden'
|
overflow: 'hidden'
|
||||||
}}>
|
}}>
|
||||||
@@ -280,16 +303,16 @@ const FeatureFlagsViewComponent = () => {
|
|||||||
fontSize: '0.875rem'
|
fontSize: '0.875rem'
|
||||||
}}>
|
}}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ backgroundColor: '#f9fafb' }}>
|
<tr style={{ backgroundColor: styles.headerBg }}>
|
||||||
<th style={{
|
<th style={{
|
||||||
padding: '0.75rem 1rem',
|
padding: '0.75rem 1rem',
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
color: '#374151',
|
color: styles.text,
|
||||||
borderBottom: '1px solid #e5e7eb',
|
borderBottom: `1px solid ${styles.border}`,
|
||||||
position: 'sticky',
|
position: 'sticky',
|
||||||
left: 0,
|
left: 0,
|
||||||
backgroundColor: '#f9fafb',
|
backgroundColor: styles.headerBg,
|
||||||
minWidth: '50px'
|
minWidth: '50px'
|
||||||
}}>
|
}}>
|
||||||
Status
|
Status
|
||||||
@@ -300,8 +323,8 @@ const FeatureFlagsViewComponent = () => {
|
|||||||
padding: '0.75rem 1rem',
|
padding: '0.75rem 1rem',
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
color: '#374151',
|
color: styles.text,
|
||||||
borderBottom: '1px solid #e5e7eb',
|
borderBottom: `1px solid ${styles.border}`,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
minWidth: '200px',
|
minWidth: '200px',
|
||||||
userSelect: 'none'
|
userSelect: 'none'
|
||||||
@@ -313,8 +336,8 @@ const FeatureFlagsViewComponent = () => {
|
|||||||
padding: '0.75rem 1rem',
|
padding: '0.75rem 1rem',
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
color: '#374151',
|
color: styles.text,
|
||||||
borderBottom: '1px solid #e5e7eb',
|
borderBottom: `1px solid ${styles.border}`,
|
||||||
minWidth: '300px'
|
minWidth: '300px'
|
||||||
}}>
|
}}>
|
||||||
Description
|
Description
|
||||||
@@ -325,8 +348,8 @@ const FeatureFlagsViewComponent = () => {
|
|||||||
padding: '0.75rem 1rem',
|
padding: '0.75rem 1rem',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
color: '#374151',
|
color: styles.text,
|
||||||
borderBottom: '1px solid #e5e7eb',
|
borderBottom: `1px solid ${styles.border}`,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
minWidth: '80px',
|
minWidth: '80px',
|
||||||
userSelect: 'none'
|
userSelect: 'none'
|
||||||
@@ -340,8 +363,8 @@ const FeatureFlagsViewComponent = () => {
|
|||||||
padding: '0.75rem 1rem',
|
padding: '0.75rem 1rem',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
color: '#374151',
|
color: styles.text,
|
||||||
borderBottom: '1px solid #e5e7eb',
|
borderBottom: `1px solid ${styles.border}`,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
minWidth: '120px',
|
minWidth: '120px',
|
||||||
userSelect: 'none'
|
userSelect: 'none'
|
||||||
@@ -353,8 +376,8 @@ const FeatureFlagsViewComponent = () => {
|
|||||||
padding: '0.75rem 1rem',
|
padding: '0.75rem 1rem',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
color: '#374151',
|
color: styles.text,
|
||||||
borderBottom: '1px solid #e5e7eb',
|
borderBottom: `1px solid ${styles.border}`,
|
||||||
minWidth: '100px'
|
minWidth: '100px'
|
||||||
}}>
|
}}>
|
||||||
Variants
|
Variants
|
||||||
@@ -363,8 +386,8 @@ const FeatureFlagsViewComponent = () => {
|
|||||||
padding: '0.75rem 1rem',
|
padding: '0.75rem 1rem',
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
color: '#374151',
|
color: styles.text,
|
||||||
borderBottom: '1px solid #e5e7eb',
|
borderBottom: `1px solid ${styles.border}`,
|
||||||
minWidth: '150px'
|
minWidth: '150px'
|
||||||
}}>
|
}}>
|
||||||
Tags
|
Tags
|
||||||
@@ -375,8 +398,8 @@ const FeatureFlagsViewComponent = () => {
|
|||||||
padding: '0.75rem 1rem',
|
padding: '0.75rem 1rem',
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
color: '#374151',
|
color: styles.text,
|
||||||
borderBottom: '1px solid #e5e7eb',
|
borderBottom: `1px solid ${styles.border}`,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
minWidth: '150px',
|
minWidth: '150px',
|
||||||
userSelect: 'none'
|
userSelect: 'none'
|
||||||
@@ -392,7 +415,7 @@ const FeatureFlagsViewComponent = () => {
|
|||||||
<td colSpan={8} style={{
|
<td colSpan={8} style={{
|
||||||
padding: '2rem',
|
padding: '2rem',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
color: '#6b7280'
|
color: styles.textMuted
|
||||||
}}>
|
}}>
|
||||||
{search ? 'No flags match your search' : 'No feature flags yet'}
|
{search ? 'No flags match your search' : 'No feature flags yet'}
|
||||||
</td>
|
</td>
|
||||||
@@ -400,10 +423,10 @@ const FeatureFlagsViewComponent = () => {
|
|||||||
) : (
|
) : (
|
||||||
filteredAndSortedFlags.map(flag => (
|
filteredAndSortedFlags.map(flag => (
|
||||||
<tr key={flag.id} style={{
|
<tr key={flag.id} style={{
|
||||||
borderBottom: '1px solid #e5e7eb',
|
borderBottom: `1px solid ${styles.border}`,
|
||||||
transition: 'background-color 0.15s',
|
transition: 'background-color 0.15s',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f9fafb'}
|
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = styles.surfaceHover}
|
||||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = ''}
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = ''}
|
||||||
>
|
>
|
||||||
<td style={{
|
<td style={{
|
||||||
@@ -417,20 +440,31 @@ const FeatureFlagsViewComponent = () => {
|
|||||||
height: '8px',
|
height: '8px',
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
backgroundColor: flag.enabled ?
|
backgroundColor: flag.enabled ?
|
||||||
(flag.rolloutPercentage && flag.rolloutPercentage < 100 ? '#f59e0b' : '#10b981')
|
(flag.rolloutPercentage && flag.rolloutPercentage < 100 ? styles.warning : styles.primary)
|
||||||
: '#ef4444'
|
: styles.error
|
||||||
}} />
|
}} />
|
||||||
</td>
|
</td>
|
||||||
<td style={{
|
<td style={{
|
||||||
padding: '0.75rem 1rem',
|
padding: '0.75rem 1rem',
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
color: '#111827'
|
color: styles.text
|
||||||
}}>
|
}}>
|
||||||
|
<a
|
||||||
|
href={`/admin/collections/feature-flags/${flag.id}`}
|
||||||
|
style={{
|
||||||
|
color: styles.info,
|
||||||
|
textDecoration: 'none',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.textDecoration = 'underline'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.textDecoration = 'none'}
|
||||||
|
>
|
||||||
{flag.name}
|
{flag.name}
|
||||||
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td style={{
|
<td style={{
|
||||||
padding: '0.75rem 1rem',
|
padding: '0.75rem 1rem',
|
||||||
color: '#6b7280'
|
color: styles.textMuted
|
||||||
}}>
|
}}>
|
||||||
{flag.description || '-'}
|
{flag.description || '-'}
|
||||||
</td>
|
</td>
|
||||||
@@ -447,7 +481,7 @@ const FeatureFlagsViewComponent = () => {
|
|||||||
width: '18px',
|
width: '18px',
|
||||||
height: '18px',
|
height: '18px',
|
||||||
cursor: saving === flag.id ? 'wait' : 'pointer',
|
cursor: saving === flag.id ? 'wait' : 'pointer',
|
||||||
accentColor: '#10b981'
|
accentColor: styles.primary
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
@@ -469,25 +503,27 @@ const FeatureFlagsViewComponent = () => {
|
|||||||
style={{
|
style={{
|
||||||
width: '60px',
|
width: '60px',
|
||||||
padding: '0.25rem 0.5rem',
|
padding: '0.25rem 0.5rem',
|
||||||
border: '1px solid #d1d5db',
|
border: `1px solid ${styles.inputBorder}`,
|
||||||
borderRadius: '0.25rem',
|
borderRadius: '0.25rem',
|
||||||
fontSize: '0.875rem',
|
fontSize: '0.875rem',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
cursor: saving === flag.id ? 'wait' : 'text',
|
cursor: saving === flag.id ? 'wait' : 'text',
|
||||||
opacity: flag.enabled ? 1 : 0.5
|
opacity: flag.enabled ? 1 : 0.5,
|
||||||
|
backgroundColor: styles.inputBg,
|
||||||
|
color: styles.text
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span style={{ color: '#6b7280' }}>%</span>
|
<span style={{ color: styles.textMuted }}>%</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td style={{
|
<td style={{
|
||||||
padding: '0.75rem 1rem',
|
padding: '0.75rem 1rem',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
color: '#6b7280'
|
color: styles.textMuted
|
||||||
}}>
|
}}>
|
||||||
{flag.variants && flag.variants.length > 0 ? (
|
{flag.variants && flag.variants.length > 0 ? (
|
||||||
<span style={{
|
<span style={{
|
||||||
backgroundColor: '#f3f4f6',
|
backgroundColor: styles.surface,
|
||||||
padding: '0.25rem 0.5rem',
|
padding: '0.25rem 0.5rem',
|
||||||
borderRadius: '0.25rem',
|
borderRadius: '0.25rem',
|
||||||
fontSize: '0.75rem'
|
fontSize: '0.75rem'
|
||||||
@@ -503,8 +539,8 @@ const FeatureFlagsViewComponent = () => {
|
|||||||
<div style={{ display: 'flex', gap: '0.25rem', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '0.25rem', flexWrap: 'wrap' }}>
|
||||||
{flag.tags.map((t, i) => (
|
{flag.tags.map((t, i) => (
|
||||||
<span key={i} style={{
|
<span key={i} style={{
|
||||||
backgroundColor: '#e0e7ff',
|
backgroundColor: styles.info + '20',
|
||||||
color: '#3730a3',
|
color: styles.info,
|
||||||
padding: '0.125rem 0.5rem',
|
padding: '0.125rem 0.5rem',
|
||||||
borderRadius: '0.25rem',
|
borderRadius: '0.25rem',
|
||||||
fontSize: '0.75rem'
|
fontSize: '0.75rem'
|
||||||
@@ -517,7 +553,7 @@ const FeatureFlagsViewComponent = () => {
|
|||||||
</td>
|
</td>
|
||||||
<td style={{
|
<td style={{
|
||||||
padding: '0.75rem 1rem',
|
padding: '0.75rem 1rem',
|
||||||
color: '#6b7280',
|
color: styles.textMuted,
|
||||||
fontSize: '0.75rem'
|
fontSize: '0.75rem'
|
||||||
}}>
|
}}>
|
||||||
{new Date(flag.updatedAt).toLocaleDateString()} {new Date(flag.updatedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
{new Date(flag.updatedAt).toLocaleDateString()} {new Date(flag.updatedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
@@ -536,7 +572,7 @@ const FeatureFlagsViewComponent = () => {
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: '2rem',
|
gap: '2rem',
|
||||||
fontSize: '0.875rem',
|
fontSize: '0.875rem',
|
||||||
color: '#6b7280'
|
color: styles.textMuted
|
||||||
}}>
|
}}>
|
||||||
<div>
|
<div>
|
||||||
<span style={{ fontWeight: '600' }}>Total:</span> {flags.filter(f => f && f.name).length} flags
|
<span style={{ fontWeight: '600' }}>Total:</span> {flags.filter(f => f && f.name).length} flags
|
||||||
|
|||||||
Reference in New Issue
Block a user