mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
refactor: extract BlockButtons component from InstallationGuide
Move button rendering logic (URL validation, copy handler, icon components) into a dedicated BlockButtons component. Remove duplicate getLocalizedText/getBaseTranslation helpers from Connection page controller.
This commit is contained in:
@@ -8,28 +8,11 @@ import type {
|
||||
RemnawavePlatformData,
|
||||
RemnawaveButtonClient,
|
||||
} from '@/types';
|
||||
import { CardsBlock, TimelineBlock, AccordionBlock, MinimalBlock } from './blocks';
|
||||
import { CardsBlock, TimelineBlock, AccordionBlock, MinimalBlock, BlockButtons } from './blocks';
|
||||
import type { BlockRendererProps } from './blocks';
|
||||
|
||||
const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV'];
|
||||
|
||||
// eslint-disable-next-line no-script-url
|
||||
const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:'];
|
||||
|
||||
function isValidDeepLink(url: string | undefined): boolean {
|
||||
if (!url) return false;
|
||||
const lowerUrl = url.toLowerCase().trim();
|
||||
if (dangerousSchemes.some((s) => lowerUrl.startsWith(s))) return false;
|
||||
return lowerUrl.includes('://');
|
||||
}
|
||||
|
||||
function isValidExternalUrl(url: string | undefined): boolean {
|
||||
if (!url) return false;
|
||||
const lowerUrl = url.toLowerCase().trim();
|
||||
if (dangerousSchemes.some((s) => lowerUrl.startsWith(s))) return false;
|
||||
return lowerUrl.startsWith('http://') || lowerUrl.startsWith('https://');
|
||||
}
|
||||
|
||||
function detectPlatform(): string | null {
|
||||
if (typeof window === 'undefined' || !navigator?.userAgent) return null;
|
||||
const ua = navigator.userAgent.toLowerCase();
|
||||
@@ -48,23 +31,6 @@ const RENDERERS: Record<string, React.ComponentType<BlockRendererProps>> = {
|
||||
minimal: MinimalBlock,
|
||||
};
|
||||
|
||||
// Icons
|
||||
const CopyIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CheckIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const BackIcon = () => (
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
@@ -91,7 +57,6 @@ export default function InstallationGuide({
|
||||
|
||||
const [activePlatformKey, setActivePlatformKey] = useState<string | null>(null);
|
||||
const [selectedApp, setSelectedApp] = useState<RemnawaveAppClient | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
@@ -155,117 +120,30 @@ export default function InstallationGuide({
|
||||
}
|
||||
}, [appConfig.platforms, availablePlatforms, selectedApp]);
|
||||
|
||||
// --- Copy ---
|
||||
|
||||
const handleCopy = useCallback(async (url: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
} catch {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = url;
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}, []);
|
||||
|
||||
// --- Button renderer (passed to block renderers) ---
|
||||
// --- Button renderer (delegates to BlockButtons component) ---
|
||||
|
||||
const renderBlockButtons = useCallback(
|
||||
(buttons: RemnawaveButtonClient[] | undefined, variant: 'light' | 'subtle') => {
|
||||
if (!buttons || buttons.length === 0) return null;
|
||||
|
||||
const baseClass =
|
||||
variant === 'light'
|
||||
? 'rounded-xl border border-accent-500/40 px-4 py-2 text-sm font-medium text-accent-400 transition-all hover:bg-accent-500/10'
|
||||
: 'rounded-xl px-3 py-1.5 text-sm font-medium text-dark-300 transition-all hover:bg-dark-700/50';
|
||||
|
||||
return (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{buttons.map((btn, idx) => {
|
||||
const btnText = getLocalizedText(btn.text);
|
||||
const btnSvg = getSvgHtml(btn.svgIconKey);
|
||||
const btnIcon = btnSvg ? (
|
||||
<div
|
||||
className="h-4 w-4 [&>svg]:h-full [&>svg]:w-full"
|
||||
dangerouslySetInnerHTML={{ __html: btnSvg }}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
if (btn.type === 'subscriptionLink') {
|
||||
const url =
|
||||
btn.resolvedUrl ||
|
||||
btn.url ||
|
||||
btn.link ||
|
||||
selectedApp?.deepLink ||
|
||||
appConfig.subscriptionUrl;
|
||||
if (!url || !isValidDeepLink(url)) return null;
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => onOpenDeepLink(url)}
|
||||
className={`flex items-center gap-2 ${baseClass}`}
|
||||
>
|
||||
{btnIcon}
|
||||
{btnText || getBaseTranslation('openApp', 'subscription.connection.openLink')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (btn.type === 'copyButton') {
|
||||
if (appConfig.hideLink) return null;
|
||||
const url = btn.resolvedUrl || appConfig.subscriptionUrl;
|
||||
if (!url) return null;
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => handleCopy(url)}
|
||||
className={`flex items-center gap-2 ${
|
||||
copied
|
||||
? 'rounded-xl border border-success-500 bg-success-500/10 px-4 py-2 text-sm font-medium text-success-400'
|
||||
: baseClass
|
||||
}`}
|
||||
>
|
||||
{copied ? <CheckIcon /> : btnIcon || <CopyIcon />}
|
||||
{copied
|
||||
? t('subscription.connection.copied')
|
||||
: btnText || getBaseTranslation('copyLink', 'subscription.connection.copyLink')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// external
|
||||
const href = btn.link || btn.url || '';
|
||||
if (!isValidExternalUrl(href)) return null;
|
||||
return (
|
||||
<a
|
||||
key={idx}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`inline-flex items-center gap-2 ${baseClass}`}
|
||||
>
|
||||
{btnIcon}
|
||||
{btnText}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
(buttons: RemnawaveButtonClient[] | undefined, variant: 'light' | 'subtle') => (
|
||||
<BlockButtons
|
||||
buttons={buttons}
|
||||
variant={variant}
|
||||
subscriptionUrl={appConfig.subscriptionUrl}
|
||||
hideLink={appConfig.hideLink}
|
||||
deepLink={selectedApp?.deepLink}
|
||||
getLocalizedText={getLocalizedText}
|
||||
getBaseTranslation={getBaseTranslation}
|
||||
getSvgHtml={getSvgHtml}
|
||||
onOpenDeepLink={onOpenDeepLink}
|
||||
/>
|
||||
),
|
||||
[
|
||||
selectedApp,
|
||||
appConfig,
|
||||
copied,
|
||||
getSvgHtml,
|
||||
appConfig.subscriptionUrl,
|
||||
appConfig.hideLink,
|
||||
selectedApp?.deepLink,
|
||||
getLocalizedText,
|
||||
getBaseTranslation,
|
||||
handleCopy,
|
||||
getSvgHtml,
|
||||
onOpenDeepLink,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
153
src/components/connection/blocks/BlockButtons.tsx
Normal file
153
src/components/connection/blocks/BlockButtons.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { RemnawaveButtonClient, LocalizedText } from '@/types';
|
||||
|
||||
// eslint-disable-next-line no-script-url
|
||||
const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:'];
|
||||
|
||||
function isValidDeepLink(url: string | undefined): boolean {
|
||||
if (!url) return false;
|
||||
const lowerUrl = url.toLowerCase().trim();
|
||||
if (dangerousSchemes.some((s) => lowerUrl.startsWith(s))) return false;
|
||||
return lowerUrl.includes('://');
|
||||
}
|
||||
|
||||
function isValidExternalUrl(url: string | undefined): boolean {
|
||||
if (!url) return false;
|
||||
const lowerUrl = url.toLowerCase().trim();
|
||||
if (dangerousSchemes.some((s) => lowerUrl.startsWith(s))) return false;
|
||||
return lowerUrl.startsWith('http://') || lowerUrl.startsWith('https://');
|
||||
}
|
||||
|
||||
const CopyIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CheckIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
interface BlockButtonsProps {
|
||||
buttons: RemnawaveButtonClient[] | undefined;
|
||||
variant: 'light' | 'subtle';
|
||||
subscriptionUrl: string | null;
|
||||
hideLink?: boolean;
|
||||
deepLink?: string | null;
|
||||
getLocalizedText: (text: LocalizedText | undefined) => string;
|
||||
getBaseTranslation: (key: string, i18nKey: string) => string;
|
||||
getSvgHtml: (key: string | undefined) => string;
|
||||
onOpenDeepLink: (url: string) => void;
|
||||
}
|
||||
|
||||
export function BlockButtons({
|
||||
buttons,
|
||||
variant,
|
||||
subscriptionUrl,
|
||||
hideLink,
|
||||
deepLink,
|
||||
getLocalizedText,
|
||||
getBaseTranslation,
|
||||
getSvgHtml,
|
||||
onOpenDeepLink,
|
||||
}: BlockButtonsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = useCallback(async (url: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
} catch {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = url;
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}, []);
|
||||
|
||||
if (!buttons || buttons.length === 0) return null;
|
||||
|
||||
const baseClass =
|
||||
variant === 'light'
|
||||
? 'rounded-xl border border-accent-500/40 px-4 py-2 text-sm font-medium text-accent-400 transition-all hover:bg-accent-500/10'
|
||||
: 'rounded-xl px-3 py-1.5 text-sm font-medium text-dark-300 transition-all hover:bg-dark-700/50';
|
||||
|
||||
return (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{buttons.map((btn, idx) => {
|
||||
const btnText = getLocalizedText(btn.text);
|
||||
const btnSvg = getSvgHtml(btn.svgIconKey);
|
||||
const btnIcon = btnSvg ? (
|
||||
<div
|
||||
className="h-4 w-4 [&>svg]:h-full [&>svg]:w-full"
|
||||
dangerouslySetInnerHTML={{ __html: btnSvg }}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
if (btn.type === 'subscriptionLink') {
|
||||
const url = btn.resolvedUrl || btn.url || btn.link || deepLink || subscriptionUrl;
|
||||
if (!url || !isValidDeepLink(url)) return null;
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => onOpenDeepLink(url)}
|
||||
className={`flex items-center gap-2 ${baseClass}`}
|
||||
>
|
||||
{btnIcon}
|
||||
{btnText || getBaseTranslation('openApp', 'subscription.connection.openLink')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (btn.type === 'copyButton') {
|
||||
if (hideLink) return null;
|
||||
const url = btn.resolvedUrl || subscriptionUrl;
|
||||
if (!url) return null;
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => handleCopy(url)}
|
||||
className={`flex items-center gap-2 ${
|
||||
copied
|
||||
? 'rounded-xl border border-success-500 bg-success-500/10 px-4 py-2 text-sm font-medium text-success-400'
|
||||
: baseClass
|
||||
}`}
|
||||
>
|
||||
{copied ? <CheckIcon /> : btnIcon || <CopyIcon />}
|
||||
{copied
|
||||
? t('subscription.connection.copied')
|
||||
: btnText || getBaseTranslation('copyLink', 'subscription.connection.copyLink')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// external
|
||||
const href = btn.link || btn.url || '';
|
||||
if (!isValidExternalUrl(href)) return null;
|
||||
return (
|
||||
<a
|
||||
key={idx}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`inline-flex items-center gap-2 ${baseClass}`}
|
||||
>
|
||||
{btnIcon}
|
||||
{btnText}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,4 +2,5 @@ export { CardsBlock } from './CardsBlock';
|
||||
export { TimelineBlock } from './TimelineBlock';
|
||||
export { AccordionBlock } from './AccordionBlock';
|
||||
export { MinimalBlock } from './MinimalBlock';
|
||||
export { BlockButtons } from './BlockButtons';
|
||||
export type { BlockRendererProps } from './types';
|
||||
|
||||
Reference in New Issue
Block a user