feat(admin): refine pro-gated settings UI

This commit is contained in:
saltbo
2026-04-27 19:33:04 -04:00
parent fb3b15a38c
commit 736d7c8d10
5 changed files with 141 additions and 183 deletions
+20 -1
View File
@@ -1,10 +1,12 @@
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
interface ProBadgeProps {
className?: string
tooltip?: string
}
export function ProBadge({ className }: ProBadgeProps) {
function ProBadgePill({ className }: { className?: string }) {
return (
<span
data-slot="pro-badge"
@@ -15,3 +17,20 @@ export function ProBadge({ className }: ProBadgeProps) {
</span>
)
}
export function ProBadge({ className, tooltip }: ProBadgeProps) {
if (!tooltip) return <ProBadgePill className={className} />
return (
<Tooltip>
<TooltipTrigger asChild>
<button type="button" className="inline-flex cursor-help appearance-none bg-transparent p-0">
<ProBadgePill className={className} />
</button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-72">
{tooltip}
</TooltipContent>
</Tooltip>
)
}
+80 -156
View File
@@ -1,15 +1,14 @@
import type { BrandingConfig, BrandingField } from '@shared/types'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Eye, ImageUp, Palette, Trash2, Upload } from 'lucide-react'
import { ImageUp, Palette, Trash2, Upload } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { brandingQueryKey } from '@/components/branding/BrandingProvider'
import { ProBadge } from '@/components/ProBadge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { useSiteOptions } from '@/hooks/use-site-options'
import { useEntitlement } from '@/hooks/useEntitlement'
import { getBranding, resetBrandingField, saveBranding } from '@/lib/api'
@@ -18,7 +17,6 @@ import { getBranding, resetBrandingField, saveBranding } from '@/lib/api'
interface BrandingFormState {
logoFile: File | null
faviconFile: File | null
hidePoweredBy: boolean
previewLogoUrl: string | null
faviconPreviewUrl: string | null
}
@@ -27,11 +25,9 @@ function useBrandingFormState(initial: BrandingConfig): {
state: BrandingFormState
setLogoFile: (f: File | null) => void
setFaviconFile: (f: File | null) => void
setHidePoweredBy: (v: boolean) => void
} {
const [logoFile, setLogoFileRaw] = useState<File | null>(null)
const [faviconFile, setFaviconFileRaw] = useState<File | null>(null)
const [hidePoweredBy, setHidePoweredBy] = useState(initial.hide_powered_by)
const [previewLogoUrl, setPreviewLogoUrl] = useState<string | null>(initial.logo_url)
const [faviconPreviewUrl, setFaviconPreviewUrl] = useState<string | null>(initial.favicon_url)
@@ -62,10 +58,9 @@ function useBrandingFormState(initial: BrandingConfig): {
}, [])
return {
state: { logoFile, faviconFile, hidePoweredBy, previewLogoUrl, faviconPreviewUrl },
state: { logoFile, faviconFile, previewLogoUrl, faviconPreviewUrl },
setLogoFile,
setFaviconFile,
setHidePoweredBy,
}
}
@@ -153,44 +148,6 @@ function FileUploadField({
)
}
function LivePreview({ branding }: { branding: BrandingConfig }) {
const { t } = useTranslation()
const { siteName } = useSiteOptions()
const logoSrc = branding.logo_url ?? '/logo.svg'
return (
<div className="rounded-[24px] border border-border/60 bg-gradient-to-br from-sidebar via-background to-muted/40 p-5 shadow-sm">
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">
<Eye className="h-4 w-4" />
<span>{t('admin.settings.branding.preview')}</span>
</div>
<div className="mt-4 space-y-4">
<div className="rounded-2xl border border-border/60 bg-background/90 p-4">
<div className="flex items-center gap-3 border-b border-border/60 pb-3">
<img src={logoSrc} alt={siteName} className="size-9 rounded-lg object-contain" />
<span className="text-lg font-semibold">{siteName}</span>
</div>
<div className="pt-3">
{!branding.hide_powered_by && (
<p className="text-xs text-center text-muted-foreground/70">{t('admin.settings.branding.poweredBy')}</p>
)}
{branding.hide_powered_by && (
<p className="text-xs text-center text-muted-foreground/70">
{t('admin.settings.branding.poweredHidden')}
</p>
)}
</div>
</div>
<div className="rounded-2xl border border-dashed border-border/70 bg-background/70 p-4">
<p className="text-sm font-medium">{t('admin.settings.branding.previewHintTitle')}</p>
<p className="mt-1 text-sm leading-6 text-muted-foreground">{t('admin.settings.branding.previewHint')}</p>
</div>
</div>
</div>
)
}
// ─── Main Section ─────────────────────────────────────────────────────────────
export function BrandingSection() {
@@ -217,14 +174,13 @@ export function BrandingSection() {
function BrandingForm({ initial, disabled }: { initial: BrandingConfig; disabled: boolean }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { state, setLogoFile, setFaviconFile, setHidePoweredBy } = useBrandingFormState(initial)
const { state, setLogoFile, setFaviconFile } = useBrandingFormState(initial)
const saveMutation = useMutation({
mutationFn: () =>
saveBranding({
logo: state.logoFile,
favicon: state.faviconFile,
hide_powered_by: state.hidePoweredBy,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: brandingQueryKey })
@@ -239,125 +195,93 @@ function BrandingForm({ initial, disabled }: { initial: BrandingConfig; disabled
queryClient.invalidateQueries({ queryKey: brandingQueryKey })
if (field === 'logo') setLogoFile(null)
if (field === 'favicon') setFaviconFile(null)
if (field === 'hide_powered_by') setHidePoweredBy(false)
toast.success(t('admin.settings.branding.resetSuccess', { field: t(`admin.settings.branding.fields.${field}`) }))
},
onError: (err) => toast.error(err.message),
})
const previewBranding: BrandingConfig = {
logo_url: state.previewLogoUrl,
favicon_url: state.faviconPreviewUrl,
wordmark_text: null,
hide_powered_by: state.hidePoweredBy,
}
return (
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1.2fr)_340px]">
<div className="space-y-6">
<Card className="border-border/60">
<CardHeader className="space-y-3">
<div className="flex items-center gap-3">
<div className="rounded-2xl border border-border/60 bg-primary/10 p-2 text-primary">
<Palette className="h-5 w-5" />
</div>
<div className="space-y-1">
<CardTitle>{t('admin.settings.branding.assetsTitle')}</CardTitle>
<CardDescription>{t('admin.settings.branding.assetsDescription')}</CardDescription>
</div>
<div className="space-y-6">
<Card className="border-border/60">
<CardHeader className="space-y-3">
<div className="flex items-center gap-3">
<div className="rounded-2xl border border-border/60 bg-primary/10 p-2 text-primary">
<Palette className="h-5 w-5" />
</div>
</CardHeader>
<CardContent className="space-y-4">
{!disabled && (
<>
<FileUploadField
id="logo-upload"
label={t('admin.settings.branding.logo')}
hint={t('admin.settings.branding.logoHint')}
emptyLabel={t('admin.settings.branding.upload')}
replaceLabel={t('admin.settings.branding.replace')}
resetLabel={t('admin.settings.branding.reset')}
accept="image/png,image/jpeg,image/webp,image/svg+xml"
previewUrl={state.previewLogoUrl}
onFileChange={setLogoFile}
onReset={() => resetMutation.mutate('logo')}
/>
<FileUploadField
id="favicon-upload"
label={t('admin.settings.branding.favicon')}
hint={t('admin.settings.branding.faviconHint')}
emptyLabel={t('admin.settings.branding.upload')}
replaceLabel={t('admin.settings.branding.replace')}
resetLabel={t('admin.settings.branding.reset')}
accept="image/png,image/x-icon,image/vnd.microsoft.icon,image/svg+xml"
previewUrl={state.faviconPreviewUrl}
onFileChange={setFaviconFile}
onReset={() => resetMutation.mutate('favicon')}
/>
</>
)}
{disabled && (
<div className="space-y-4 rounded-2xl border border-dashed border-border/70 bg-muted/25 p-4 text-sm text-muted-foreground">
<p>{t('admin.settings.branding.lockedMessage')}</p>
<FileUploadField
id="logo-upload-disabled"
label={t('admin.settings.branding.logo')}
hint={t('admin.settings.branding.logoHint')}
emptyLabel={t('admin.settings.branding.upload')}
replaceLabel={t('admin.settings.branding.replace')}
resetLabel={t('admin.settings.branding.reset')}
disabled
accept="image/png,image/jpeg,image/webp,image/svg+xml"
previewUrl={state.previewLogoUrl}
onFileChange={() => {}}
onReset={() => {}}
/>
<FileUploadField
id="favicon-upload-disabled"
label={t('admin.settings.branding.favicon')}
hint={t('admin.settings.branding.faviconHint')}
emptyLabel={t('admin.settings.branding.upload')}
replaceLabel={t('admin.settings.branding.replace')}
resetLabel={t('admin.settings.branding.reset')}
disabled
accept="image/png,image/x-icon,image/vnd.microsoft.icon,image/svg+xml"
previewUrl={state.faviconPreviewUrl}
onFileChange={() => {}}
onReset={() => {}}
/>
<div className="space-y-1">
<div className="flex items-center gap-2">
<CardTitle>{t('admin.settings.branding.assetsTitle')}</CardTitle>
<ProBadge tooltip={t('admin.settings.proLockedWhiteLabel')} />
</div>
)}
</CardContent>
</Card>
<Card className="border-border/60">
<CardHeader className="space-y-1">
<CardTitle>{t('admin.settings.branding.visibilityTitle')}</CardTitle>
<CardDescription>{t('admin.settings.branding.visibilityDescription')}</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between gap-4 rounded-2xl border border-border/60 bg-background p-4">
<div className="space-y-0.5">
<Label htmlFor="hide-powered-by">{t('admin.settings.branding.hidePoweredBy')}</Label>
<p className="text-xs text-muted-foreground">{t('admin.settings.branding.hidePoweredByHint')}</p>
</div>
<Switch
id="hide-powered-by"
checked={state.hidePoweredBy}
disabled={disabled}
onCheckedChange={setHidePoweredBy}
<CardDescription>{t('admin.settings.branding.assetsDescription')}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{!disabled && (
<>
<FileUploadField
id="logo-upload"
label={t('admin.settings.branding.logo')}
hint={t('admin.settings.branding.logoHint')}
emptyLabel={t('admin.settings.branding.upload')}
replaceLabel={t('admin.settings.branding.replace')}
resetLabel={t('admin.settings.branding.reset')}
accept="image/png,image/jpeg,image/webp,image/svg+xml"
previewUrl={state.previewLogoUrl}
onFileChange={setLogoFile}
onReset={() => resetMutation.mutate('logo')}
/>
<FileUploadField
id="favicon-upload"
label={t('admin.settings.branding.favicon')}
hint={t('admin.settings.branding.faviconHint')}
emptyLabel={t('admin.settings.branding.upload')}
replaceLabel={t('admin.settings.branding.replace')}
resetLabel={t('admin.settings.branding.reset')}
accept="image/png,image/x-icon,image/vnd.microsoft.icon,image/svg+xml"
previewUrl={state.faviconPreviewUrl}
onFileChange={setFaviconFile}
onReset={() => resetMutation.mutate('favicon')}
/>
</>
)}
{disabled && (
<div className="space-y-4 rounded-2xl border border-dashed border-border/70 bg-muted/25 p-4 text-sm text-muted-foreground">
<FileUploadField
id="logo-upload-disabled"
label={t('admin.settings.branding.logo')}
hint={t('admin.settings.branding.logoHint')}
emptyLabel={t('admin.settings.branding.upload')}
replaceLabel={t('admin.settings.branding.replace')}
resetLabel={t('admin.settings.branding.reset')}
disabled
accept="image/png,image/jpeg,image/webp,image/svg+xml"
previewUrl={state.previewLogoUrl}
onFileChange={() => {}}
onReset={() => {}}
/>
<FileUploadField
id="favicon-upload-disabled"
label={t('admin.settings.branding.favicon')}
hint={t('admin.settings.branding.faviconHint')}
emptyLabel={t('admin.settings.branding.upload')}
replaceLabel={t('admin.settings.branding.replace')}
resetLabel={t('admin.settings.branding.reset')}
disabled
accept="image/png,image/x-icon,image/vnd.microsoft.icon,image/svg+xml"
previewUrl={state.faviconPreviewUrl}
onFileChange={() => {}}
onReset={() => {}}
/>
</div>
</CardContent>
</Card>
)}
</CardContent>
</Card>
<Button onClick={() => saveMutation.mutate()} disabled={disabled || saveMutation.isPending}>
{saveMutation.isPending ? t('admin.settings.branding.saving') : t('admin.settings.branding.save')}
</Button>
</div>
<div className="space-y-2 xl:sticky xl:top-4 xl:self-start">
<LivePreview branding={previewBranding} />
</div>
<Button onClick={() => saveMutation.mutate()} disabled={disabled || saveMutation.isPending}>
{saveMutation.isPending ? t('admin.settings.branding.saving') : t('admin.settings.branding.save')}
</Button>
</div>
)
}
+4 -2
View File
@@ -154,12 +154,14 @@
"admin.settings.metricExperienceHint": "Use custom assets and copy to make the instance feel like your own product.",
"admin.settings.identityTitle": "Site identity",
"admin.settings.identityDescription": "Set the primary name and short description users see across the product.",
"admin.settings.identityProTooltip": "Custom site name and description are available in ZPan Pro.",
"admin.settings.proLockedWhiteLabel": "This section is a ZPan Pro feature. Without Pro, site name, description, branding, and footer copyright controls stay visible but locked.",
"admin.settings.registrationTitle": "New user registration",
"admin.settings.registrationDescription": "Control whether new users are allowed to create accounts on this instance.",
"admin.settings.registrationLabel": "Allow new user sign-ups",
"admin.settings.registrationHintOpen": "New users can register directly from the sign-up page.",
"admin.settings.registrationHintClosed": "New account creation is disabled for this instance.",
"admin.settings.registrationUpgradeHint": "Open registration requires ZPan Pro.",
"admin.settings.registrationUpgradeHint": "Open registration is disabled without ZPan Pro.",
"admin.settings.siteNamePlaceholder": "ZPan Workspace",
"admin.settings.siteNameHint": "Keep it short and recognizable. It will be reused in navigation and browser chrome.",
"admin.settings.siteDescriptionPlaceholder": "Private file hosting for your team, customers, or community.",
@@ -181,7 +183,7 @@
"admin.settings.branding.poweredHidden": "Footer credit hidden",
"admin.settings.branding.previewHintTitle": "What changes here",
"admin.settings.branding.previewHint": "Logo and favicon updates affect shared surfaces immediately after saving.",
"admin.settings.branding.assetsTitle": "Logo and favicon",
"admin.settings.branding.assetsTitle": "Branding",
"admin.settings.branding.assetsDescription": "Upload the assets users will see in the sidebar, browser tab, and public entry points.",
"admin.settings.branding.logo": "Logo",
"admin.settings.branding.logoHint": "Best for square or compact marks. PNG, JPG, WebP, or SVG.",
+4 -2
View File
@@ -154,12 +154,14 @@
"admin.settings.metricExperienceHint": "通过品牌素材和文案,让实例更像你自己的产品,而不是默认安装页。",
"admin.settings.identityTitle": "站点标识",
"admin.settings.identityDescription": "设置用户在整个产品中都会看到的站点名称和简短介绍。",
"admin.settings.identityProTooltip": "自定义站点名称和描述仅在 ZPan Pro 中可用。",
"admin.settings.proLockedWhiteLabel": "这个区块属于 ZPan Pro。未激活 Pro 时,站点名称、描述、Branding 和底部署名控制会继续显示,但会保持禁用。",
"admin.settings.registrationTitle": "新用户注册",
"admin.settings.registrationDescription": "控制这个实例是否允许新用户自行创建账号。",
"admin.settings.registrationLabel": "允许新用户注册",
"admin.settings.registrationHintOpen": "新用户可以直接从注册页创建账号。",
"admin.settings.registrationHintClosed": "当前实例已关闭新账号注册。",
"admin.settings.registrationUpgradeHint": "开放注册需要 ZPan Pro。",
"admin.settings.registrationUpgradeHint": "未激活 ZPan Pro 时,不允许开放注册。",
"admin.settings.siteNamePlaceholder": "ZPan Workspace",
"admin.settings.siteNameHint": "保持简短且易识别,它会被复用到导航和浏览器标题等位置。",
"admin.settings.siteDescriptionPlaceholder": "为你的团队、客户或社区提供私有文件托管服务。",
@@ -181,7 +183,7 @@
"admin.settings.branding.poweredHidden": "底部署名已隐藏",
"admin.settings.branding.previewHintTitle": "这里会变化什么",
"admin.settings.branding.previewHint": "保存后,Logo 和 favicon 会立即影响到共享界面和应用入口。",
"admin.settings.branding.assetsTitle": "Logo 与 Favicon",
"admin.settings.branding.assetsTitle": "Branding",
"admin.settings.branding.assetsDescription": "上传会出现在侧边栏、浏览器标签页和公共入口中的品牌素材。",
"admin.settings.branding.logo": "Logo",
"admin.settings.branding.logoHint": "适合方形或紧凑型标识。支持 PNG、JPG、WebP、SVG。",
@@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { z } from 'zod'
import { BrandingSection } from '@/components/admin/branding-section'
import { ProBadge } from '@/components/ProBadge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
@@ -43,11 +44,24 @@ const settingsSchema = z.object({
type SettingsFormValues = z.infer<typeof settingsSchema>
function ProFeatureHeader({ title, description, tooltip }: { title: string; description: string; tooltip: string }) {
return (
<div className="space-y-1">
<div className="flex items-center gap-2">
<CardTitle>{title}</CardTitle>
<ProBadge tooltip={tooltip} />
</div>
<CardDescription>{description}</CardDescription>
</div>
)
}
function SettingsPage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { siteName, siteDescription, defaultOrgQuota: quotaBytes, authSignupMode, isLoading } = useSiteOptions()
const { hasFeature } = useEntitlement()
const hasWhiteLabel = hasFeature('white_label')
const hasOpenRegistration = hasFeature('open_registration')
const form = useForm<SettingsFormValues>({
@@ -112,17 +126,21 @@ function SettingsPage() {
<div className="rounded-2xl border border-border/60 bg-primary/10 p-2 text-primary">
<Globe2 className="h-5 w-5" />
</div>
<div className="space-y-1">
<CardTitle>{t('admin.settings.identityTitle')}</CardTitle>
<CardDescription>{t('admin.settings.identityDescription')}</CardDescription>
</div>
<ProFeatureHeader
title={t('admin.settings.identityTitle')}
description={t('admin.settings.identityDescription')}
tooltip={t('admin.settings.identityProTooltip')}
/>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<div className={`space-y-2 ${!hasWhiteLabel ? 'opacity-60' : ''}`}>
<Label htmlFor="siteName">{t('admin.settings.siteName')}</Label>
<Input
id="siteName"
readOnly={!hasWhiteLabel}
aria-disabled={!hasWhiteLabel}
tabIndex={!hasWhiteLabel ? -1 : undefined}
placeholder={t('admin.settings.siteNamePlaceholder')}
{...form.register('siteName')}
/>
@@ -132,11 +150,14 @@ function SettingsPage() {
)}
</div>
<div className="space-y-2">
<div className={`space-y-2 ${!hasWhiteLabel ? 'opacity-60' : ''}`}>
<Label htmlFor="siteDescription">{t('admin.settings.siteDescription')}</Label>
<Textarea
id="siteDescription"
rows={4}
readOnly={!hasWhiteLabel}
aria-disabled={!hasWhiteLabel}
tabIndex={!hasWhiteLabel ? -1 : undefined}
placeholder={t('admin.settings.siteDescriptionPlaceholder')}
{...form.register('siteDescription')}
/>
@@ -154,10 +175,11 @@ function SettingsPage() {
<div className="rounded-2xl border border-border/60 bg-amber-500/10 p-2 text-amber-600">
<Globe2 className="h-5 w-5" />
</div>
<div className="space-y-1">
<CardTitle>{t('admin.settings.registrationTitle')}</CardTitle>
<CardDescription>{t('admin.settings.registrationDescription')}</CardDescription>
</div>
<ProFeatureHeader
title={t('admin.settings.registrationTitle')}
description={t('admin.settings.registrationDescription')}
tooltip={t('admin.settings.registrationUpgradeHint')}
/>
</div>
</CardHeader>
<CardContent>
@@ -169,11 +191,6 @@ function SettingsPage() {
? t('admin.settings.registrationHintOpen')
: t('admin.settings.registrationHintClosed')}
</p>
{!hasOpenRegistration && (
<p className="text-xs leading-5 text-muted-foreground">
{t('admin.settings.registrationUpgradeHint')}
</p>
)}
</div>
<Switch
id="registrationsEnabled"
@@ -241,13 +258,7 @@ function SettingsPage() {
</Card>
</form>
<div className="space-y-4">
<div className="space-y-1">
<h2 className="text-xl font-semibold">{t('admin.settings.brandingTitle')}</h2>
<p className="text-sm text-muted-foreground">{t('admin.settings.brandingDescription')}</p>
</div>
<BrandingSection />
</div>
<BrandingSection />
</div>
)
}