From 736d7c8d10a9f852723ce52400bc37e7aaf2bf2c Mon Sep 17 00:00:00 2001 From: saltbo Date: Mon, 27 Apr 2026 19:33:04 -0400 Subject: [PATCH] feat(admin): refine pro-gated settings UI --- src/components/ProBadge.tsx | 21 +- src/components/admin/branding-section.tsx | 236 ++++++------------ src/i18n/locales/en.json | 6 +- src/i18n/locales/zh.json | 6 +- .../_authenticated/admin/settings/index.tsx | 55 ++-- 5 files changed, 141 insertions(+), 183 deletions(-) diff --git a/src/components/ProBadge.tsx b/src/components/ProBadge.tsx index 10b3f89e..00b8601e 100644 --- a/src/components/ProBadge.tsx +++ b/src/components/ProBadge.tsx @@ -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 ( ) } + +export function ProBadge({ className, tooltip }: ProBadgeProps) { + if (!tooltip) return + + return ( + + + + + + {tooltip} + + + ) +} diff --git a/src/components/admin/branding-section.tsx b/src/components/admin/branding-section.tsx index 8d1d8779..4b6990f6 100644 --- a/src/components/admin/branding-section.tsx +++ b/src/components/admin/branding-section.tsx @@ -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(null) const [faviconFile, setFaviconFileRaw] = useState(null) - const [hidePoweredBy, setHidePoweredBy] = useState(initial.hide_powered_by) const [previewLogoUrl, setPreviewLogoUrl] = useState(initial.logo_url) const [faviconPreviewUrl, setFaviconPreviewUrl] = useState(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 ( -
-
- - {t('admin.settings.branding.preview')} -
-
-
-
- {siteName} - {siteName} -
-
- {!branding.hide_powered_by && ( -

{t('admin.settings.branding.poweredBy')}

- )} - {branding.hide_powered_by && ( -

- {t('admin.settings.branding.poweredHidden')} -

- )} -
-
- -
-

{t('admin.settings.branding.previewHintTitle')}

-

{t('admin.settings.branding.previewHint')}

-
-
-
- ) -} - // ─── 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 ( -
-
- - -
-
- -
-
- {t('admin.settings.branding.assetsTitle')} - {t('admin.settings.branding.assetsDescription')} -
+
+ + +
+
+
- - - {!disabled && ( - <> - resetMutation.mutate('logo')} - /> - resetMutation.mutate('favicon')} - /> - - )} - {disabled && ( -
-

{t('admin.settings.branding.lockedMessage')}

- {}} - onReset={() => {}} - /> - {}} - onReset={() => {}} - /> +
+
+ {t('admin.settings.branding.assetsTitle')} +
- )} - - - - - - {t('admin.settings.branding.visibilityTitle')} - {t('admin.settings.branding.visibilityDescription')} - - -
-
- -

{t('admin.settings.branding.hidePoweredByHint')}

-
- {t('admin.settings.branding.assetsDescription')} +
+
+ + + {!disabled && ( + <> + resetMutation.mutate('logo')} + /> + resetMutation.mutate('favicon')} + /> + + )} + {disabled && ( +
+ {}} + onReset={() => {}} + /> + {}} + onReset={() => {}} />
-
- + )} + + - -
-
- -
+
) } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index a9768dcd..d2c20e31 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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.", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index f905a852..a37d0a06 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -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。", diff --git a/src/routes/_authenticated/admin/settings/index.tsx b/src/routes/_authenticated/admin/settings/index.tsx index f68d3bc9..6296caed 100644 --- a/src/routes/_authenticated/admin/settings/index.tsx +++ b/src/routes/_authenticated/admin/settings/index.tsx @@ -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 +function ProFeatureHeader({ title, description, tooltip }: { title: string; description: string; tooltip: string }) { + return ( +
+
+ {title} + +
+ {description} +
+ ) +} + 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({ @@ -112,17 +126,21 @@ function SettingsPage() {
-
- {t('admin.settings.identityTitle')} - {t('admin.settings.identityDescription')} -
+
-
+
@@ -132,11 +150,14 @@ function SettingsPage() { )}
-
+