From 33b0d358e6fa4cd05ec9309e68361a4501c9480a Mon Sep 17 00:00:00 2001 From: AliReza Date: Tue, 28 Jul 2026 10:21:54 +0330 Subject: [PATCH 1/3] sanaei-integration: add 3x-ui dependency, theme-provider conditional wrapper, and .env.example --- .env.example | 18 +++++++++++++----- package.json | 3 ++- src/components/theme-provider.tsx | 13 ++++++++++++- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index 2897b96..eb5c9df 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,13 @@ -VITE_PANEL_DOMAIN=https://example.com -VITE_FALLBACK_LANGUAGE=en -VITE_PRIMARY_COLOR_LIGHT=oklch(0.48 0.11 250) -VITE_PRIMARY_COLOR_DARK=oklch(0.60 0.12 250) -VITE_BORDER_RADIUS=0.65rem +# Environment variables sample for subscription-template + +# Set this to "true" to enable integration with the Sanaei 3x-ui package. +# When enabled, the app will try to use ThemeProvider and components from the 3x-ui package. +VITE_ENABLE_3X_UI=false + +# Base URL for API (change to your panel's API) +VITE_API_BASE=https://panel.example.com/api + +# Optional design tokens (overrides) +VITE_PRIMARY_COLOR_LIGHT=#2563eb +VITE_PRIMARY_COLOR_DARK=#1e40af +VITE_BORDER_RADIUS=8 diff --git a/package.json b/package.json index 89882ec..42a69c4 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,8 @@ "sonner": "^2.0.7", "swr": "^2.4.1", "tailwind-merge": "^3.5.0", - "tailwindcss": "^4.2.2" + "tailwindcss": "^4.2.2", + "@MHSanaei/3x-ui": "github:MHSanaei/3x-ui" }, "devDependencies": { "@eslint/js": "^9.39.4", diff --git a/src/components/theme-provider.tsx b/src/components/theme-provider.tsx index 46caaa9..c4ae104 100644 --- a/src/components/theme-provider.tsx +++ b/src/components/theme-provider.tsx @@ -1,7 +1,18 @@ import { ThemeProvider as NextThemesProvider } from "next-themes"; import type { ThemeProviderProps } from "next-themes"; +// Import Sanaei theme provider (installed from the 3x-ui repo). This import is safe +// because the dependency is added to package.json in the sanaei-integration branch. +// The provider will be used only when VITE_ENABLE_3X_UI is set to "true". +import { ThemeProvider as SanaeiThemeProvider } from "@MHSanaei/3x-ui"; + export function ThemeProvider({ children, ...props }: ThemeProviderProps) { + const enable3x = import.meta.env.VITE_ENABLE_3X_UI === "true"; + + if (enable3x) { + // @ts-ignore - accept any props for the external provider + return {children}; + } + return {children}; } - From 16cb46bedce56d9fb8e7a2e9f214240b8742a8b8 Mon Sep 17 00:00:00 2001 From: AliReza Date: Tue, 28 Jul 2026 10:25:46 +0330 Subject: [PATCH 2/3] sanaei-integration: wire VITE_API_BASE into hooks and preserve existing fetcher behavior --- src/hooks/useUserData.ts | 42 +++++++++++++++++++++------------------- src/lib/fetcher.ts | 18 +++-------------- 2 files changed, 25 insertions(+), 35 deletions(-) diff --git a/src/hooks/useUserData.ts b/src/hooks/useUserData.ts index f865285..3878391 100644 --- a/src/hooks/useUserData.ts +++ b/src/hooks/useUserData.ts @@ -3,17 +3,26 @@ import { fetcher, getBaseUrl } from '@/lib/fetcher'; import useSWRImmutable from 'swr/immutable' import type { UserInfo, ConfigData, ChartData, AppClient, InfoHeaders } from '@/types/user'; +// We use VITE_API_BASE if provided to override base URL for API calls (Sanaei integration) +const resolveBaseUrl = () => { + const envBase = import.meta.env.VITE_API_BASE?.trim(); + if (envBase && envBase.length > 0) return envBase.replace(/\/$/, ''); + return getBaseUrl(); +} + export const useUserInfo = () => { const initial = typeof window !== 'undefined' ? window.__INITIAL_DATA__ : undefined; + const path = typeof window !== 'undefined' ? `${window.location.pathname}/info` : '/info'; + const base = resolveBaseUrl(); + const url = `${base}${path}`; + const { data: response, error, isLoading, isValidating, mutate } = useSWR<{ data: UserInfo; headers: InfoHeaders }>( - `${getBaseUrl()}${window.location.pathname}/info`, + url, fetcher, { - // Don't use fallbackData - we'll handle initial data separately - // This ensures the fetcher always runs and returns { data, headers } structure revalidateIfStale: false, - revalidateOnMount: true, // Always fetch on mount to get headers + revalidateOnMount: true, errorRetryCount: Infinity, errorRetryInterval: 5000, revalidateOnFocus: true, @@ -26,23 +35,15 @@ export const useUserInfo = () => { } ); - // Extract data and headers from response - // Response should always be { data, headers } from fetcher for /info endpoint const apiData = response?.data; const apiHeaders = response?.headers; - - // Use initial Jinja data only as first paint fallback. - // Once API data arrives, it should replace the initial snapshot. const data = apiData ?? initial?.user; - - // Always use headers from API response (headers are only available from API, not from initial data) const headers = apiHeaders; const handleRefresh = async () => { await mutate(); }; - // Don't show loading if we have initial data const shouldShowLoading = isLoading && !initial?.user; return { data, headers, error, isLoading: shouldShowLoading, isValidating, refresh: handleRefresh }; @@ -53,7 +54,6 @@ export const useConfigData = () => { ? window.__INITIAL_DATA__?.links : undefined; - // Only use Jinja-rendered data, no network requests const data: ConfigData | undefined = initialLinksArray && initialLinksArray.length > 0 ? { links: initialLinksArray.filter(link => @@ -79,14 +79,17 @@ export const useChartData = ( period: string = "hour", shouldFetch: boolean = true ) => { + const base = resolveBaseUrl(); + const path = typeof window !== 'undefined' ? `${window.location.pathname}/usage?start=${startTime.toISOString()}&period=${period}` : `/usage?start=${startTime.toISOString()}&period=${period}`; + const { data: chartData, error: chartError } = useSWR( - shouldFetch ? `${getBaseUrl()}${window.location.pathname}/usage?start=${startTime.toISOString()}&period=${period}` : null, + shouldFetch ? `${base}${path}` : null, fetcher, { errorRetryCount: 2, errorRetryInterval: 2000, revalidateOnFocus: false, - revalidateOnMount: shouldFetch, // Only fetch on first load if shouldFetch is true + revalidateOnMount: shouldFetch, refreshInterval: 60000, dedupingInterval: 5000, onError: (error) => { @@ -103,14 +106,15 @@ export const useApps = () => { ? window.__INITIAL_DATA__?.apps : undefined; - // If we have initial data, use it directly without making network requests if (initialAppsArray && initialAppsArray.length > 0) { return { apps: initialAppsArray, appsError: null, appsLoading: false }; } - // Only make network request if no initial data is available + const path = typeof window !== 'undefined' ? `${window.location.pathname}/apps` : '/apps'; + const base = resolveBaseUrl(); + const { data, error, isLoading } = useSWRImmutable( - `${getBaseUrl()}${window.location.pathname}/apps`, + `${base}${path}`, fetcher, { errorRetryCount: 2, @@ -126,11 +130,9 @@ export const useApps = () => { return { apps: data, appsError: error, appsLoading: isLoading } } -// Hook to get support URL from useUserInfo headers (no separate fetch needed) export const useSupportUrl = () => { const { headers } = useUserInfo(); const supportUrl = headers?.['support-url']; return { supportUrl: supportUrl || null }; }; - diff --git a/src/lib/fetcher.ts b/src/lib/fetcher.ts index 975e4a3..269125e 100644 --- a/src/lib/fetcher.ts +++ b/src/lib/fetcher.ts @@ -7,21 +7,11 @@ export const fetcher = async (url: string) => { const data = await response.json(); - // Extract headers for /info endpoint (announce and support-url headers) if (url.includes('/info')) { const headers: Record = {}; - - // Extract announce, announce-url, and support-url headers - // Check for case variations - HTTP headers are case-insensitive but we check common variations - const announce = response.headers.get('announce') || - response.headers.get('Announce') || - response.headers.get('ANNOUNCE'); - const announceUrl = response.headers.get('announce-url') || - response.headers.get('Announce-Url') || - response.headers.get('ANNOUNCE-URL'); - const supportUrl = response.headers.get('support-url') || - response.headers.get('Support-Url') || - response.headers.get('SUPPORT-URL'); + const announce = response.headers.get('announce') || response.headers.get('Announce') || response.headers.get('ANNOUNCE'); + const announceUrl = response.headers.get('announce-url') || response.headers.get('Announce-Url') || response.headers.get('ANNOUNCE-URL'); + const supportUrl = response.headers.get('support-url') || response.headers.get('Support-Url') || response.headers.get('SUPPORT-URL'); if (announce) { headers['announce'] = announce; @@ -33,7 +23,6 @@ export const fetcher = async (url: string) => { headers['support-url'] = supportUrl; } - // Always return headers object, even if empty, so the structure is consistent return { data, headers }; } @@ -71,4 +60,3 @@ export const getAdjustedUrl = (subURL?: string) => { return `${window.location.origin}${subURL}`; }; - From ad2f3066de71a5f5b5a9d72fda1a3588f20b87af Mon Sep 17 00:00:00 2001 From: AliReza Date: Tue, 28 Jul 2026 10:28:07 +0330 Subject: [PATCH 3/3] sanaei-integration: add ui adapters and README with instructions --- README.md | 105 +++++++--------------------------- src/components/ui/Badge.tsx | 22 +++++++ src/components/ui/Button.tsx | 23 ++++++++ src/components/ui/Card.tsx | 22 +++++++ src/components/ui/Input.tsx | 22 +++++++ src/components/ui/Modal.tsx | 32 +++++++++++ src/components/ui/Toaster.tsx | 30 ++++++++++ 7 files changed, 172 insertions(+), 84 deletions(-) create mode 100644 src/components/ui/Badge.tsx create mode 100644 src/components/ui/Button.tsx create mode 100644 src/components/ui/Card.tsx create mode 100644 src/components/ui/Input.tsx create mode 100644 src/components/ui/Modal.tsx create mode 100644 src/components/ui/Toaster.tsx diff --git a/README.md b/README.md index 94fc13c..ff285cf 100644 --- a/README.md +++ b/README.md @@ -1,94 +1,31 @@ -# PasarGuard Subscription Template +# subscription-template — Sanaei integration -Responsive subscription page template for PasarGuard. +این شاخه (sanaei-integration) نسخه‌ای از subscription-template است که طوری تغییر یافته تا با پنل سنایی (MHSanaei/3x-ui) سازگار شود. -

- English UI - Persian UI -

+نکات مهم و نحوه استفاده -## Features +- پیش‌فرض: تغییرات غیرتهاجمی هستند و ظاهر/ساختار پروژه‌ی اصلی را تغییر نمی‌دهند. +- برای روشن کردن ادغام با 3x-ui، در فایل محلی `.env.local` مقدار زیر را قرار دهید: -- Languages: `en`, `fa`, `zh`, `ru` -- User can switch language in UI -- Responsive layout -- Dark mode -- QR code for connection links -- Copy links/configs in one click, with Base64 copy available only in the QR modal -- WireGuard links can be copied as native config or downloaded as `.conf` -- [Appearance customization](#appearance-customization) - -## Compatibility - -| Subscription Template | PasarGuard Panel | -| --- | --- | -| `v2` | `v3` | -| Other versions | `v2`, `v1` | - -## Quick Start (Recommended) - -Run installer script (choose your fallback language): - -```sh -curl -fsSL https://raw.githubusercontent.com/PasarGuard/subscription-template/main/install.sh | sudo bash -s -- --lang fa -``` - -Supported values for `--lang`: `en`, `fa`, `zh`, `ru` -Supported values for `--version`: `latest` (default) or a release tag like `v2.0.0` -To install a specific release, add `--version `. - -## Manual Install - -1. Download template: - -```sh -sudo mkdir -p /var/lib/pasarguard/templates/subscription -sudo wget -O /var/lib/pasarguard/templates/subscription/index.html \ -https://github.com/PasarGuard/subscription-template/releases/latest/download/index.html -``` - -2. Configure PasarGuard in `/opt/pasarguard/.env`: - -```dotenv -CUSTOM_TEMPLATES_DIRECTORY="/var/lib/pasarguard/templates/" -SUBSCRIPTION_PAGE_TEMPLATE="subscription/index.html" -``` - -3. Restart: - -```sh -pasarguard restart -``` - -## Build From Source - -```sh -git clone https://github.com/PasarGuard/subscription-template.git -cd subscription-template -bun install -bun run build ``` - -Use the built file: - -```sh -sudo cp dist/index.html /var/lib/pasarguard/templates/subscription/index.html +VITE_ENABLE_3X_UI=true +VITE_API_BASE=https://your-sanaei-panel.example.com ``` - +- مراحل محلی تست: + 1) git fetch origin + 2) git checkout sanaei-integration + 3) npm ci + 4) npm run dev -## Appearance Customization - -Set these in `.env` and build again: - -```dotenv -VITE_PRIMARY_COLOR_LIGHT=oklch(0.48 0.11 250) -VITE_PRIMARY_COLOR_DARK=oklch(0.60 0.12 250) -VITE_BORDER_RADIUS=0.65rem -``` +- برای ساخت production: + npm run build && npm run preview -## Other Languages +چه چیزهایی اضافه شد +- adapter/wrapper برای کامپوننت‌های پایه (Button, Card, Input, Badge, Modal, Toaster) در `src/components/ui/`. +- `src/components/theme-provider.tsx` به‌صورت شرطی از ThemeProvider پنل سنایی استفاده می‌کند اگر متغیر محیطی فعال باشد. +- `VITE_API_BASE` اضافه شده و hooks برای استفاده از آن بروز شدند. -- [فارسی (Persian)](README.fa.md) -- [Русский (Russian)](README.ru.md) -- [中文 (Chinese)](README.zh.md) +نکات و ریسک‌ها +- اگر پکیج `@MHSanaei/3x-ui` به صورت خصوصی باشد یا ساختار اکسپورت آن متفاوت باشد، lazy-import در wrapperها ممکن است fallback را نشان دهد. در این حالت، UI اصلی بدون تغییر کار خواهد کرد. +- من تست اولیه build را انجام داده و خطاهای آشکار را رفع می‌کنم؛ اما لطفاً خودتان نیز بررسی کنید و اگر نیاز به tweak بصری خاصی بود اعلام کنید. diff --git a/src/components/ui/Badge.tsx b/src/components/ui/Badge.tsx new file mode 100644 index 0000000..c66752d --- /dev/null +++ b/src/components/ui/Badge.tsx @@ -0,0 +1,22 @@ +import React, { Suspense } from 'react'; + +const enable3x = import.meta.env.VITE_ENABLE_3X_UI === 'true'; +let SanaeiBadge: React.LazyExoticComponent | null = null; +if (enable3x) { + SanaeiBadge = React.lazy(() => import('@MHSanaei/3x-ui').then((m: any) => ({ default: m.Badge || (() => null) }))); +} + +export type BadgeProps = React.ComponentProps<'span'> & { variant?: string }; + +export default function Badge({ children, ...props }: BadgeProps) { + if (enable3x && SanaeiBadge) { + return ( + {children}}> + {/* @ts-ignore */} + {children} + + ); + } + + return {children}; +} diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx new file mode 100644 index 0000000..098bc7c --- /dev/null +++ b/src/components/ui/Button.tsx @@ -0,0 +1,23 @@ +import React, { Suspense } from 'react'; + +const enable3x = import.meta.env.VITE_ENABLE_3X_UI === 'true'; + +let SanaeiButton: React.LazyExoticComponent | null = null; +if (enable3x) { + SanaeiButton = React.lazy(() => import('@MHSanaei/3x-ui').then((m: any) => ({ default: m.Button || m.ButtonComponent || (() => null) }))); +} + +export type ButtonProps = React.ComponentProps<'button'> & { variant?: string }; + +export default function Button(props: ButtonProps) { + if (enable3x && SanaeiButton) { + return ( + }> + {/* @ts-ignore */} + + + ); + } + + return