gitscope
← marzneshin / Diff · 0178858 → 0178858
Changes 0178858..0178858
Patch commit
diff --git a/app/db/migrations/versions/9001356f0063_create_settings.py b/app/db/migrations/versions/9001356f0063_create_settings.py
new file mode 100644
index 0000000..9b51fd9
--- /dev/null
+++ b/app/db/migrations/versions/9001356f0063_create_settings.py
@@ -0,0 +1,70 @@
+"""empty message
+
+Revision ID: 9001356f0063
+Revises: 058eb11c5b17
+Create Date: 2024-06-08 21:59:45.214144
+
+"""
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision = "9001356f0063"
+down_revision = "058eb11c5b17"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ settings_table = op.create_table(
+ "settings",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("subscription", sa.JSON(), nullable=False),
+ sa.Column("telegram", sa.JSON()),
+ sa.PrimaryKeyConstraint("id"),
+ )
+ # ### end Alembic commands ###
+ op.bulk_insert(
+ settings_table,
+ [
+ {
+ "telegram": None,
+ "subscription": {
+ "url_prefix": "",
+ "profile_title": "Support",
+ "support_link": "t.me/support",
+ "update_interval": 12,
+ "rules": [
+ {
+ "pattern": "^([Cc]lash-verge|[Cc]lash-?[Mm]eta)",
+ "result": "clash-meta",
+ },
+ {"pattern": "^([Cc]lash|[Ss]tash)", "result": "clash"},
+ {
+ "pattern": "^(SFA|SFI|SFM|SFT|[Kk]aring|[Hh]iddify[Nn]ext)",
+ "result": "sing-box",
+ },
+ {
+ "pattern": "^v2rayN/(:?6\.(:?[5-9]\d+|4[1-9])|[7-9]\d*\.\d+)",
+ "result": "xray",
+ },
+ {"pattern": "^v2rayN/", "result": "base64-links"},
+ {
+ "pattern": "^v2rayNG/([2-9]|1\.(9|\d{2,})|1\.8\.(1[6-9]|[2-9]\d|\d{3,}))",
+ "result": "xray",
+ },
+ {"pattern": "^v2rayNG/", "result": "base64-links"},
+ {"pattern": ".*", "result": "template"},
+ ],
+ },
+ }
+ ],
+ )
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_table("settings")
+ # ### end Alembic commands ###
diff --git a/app/db/models.py b/app/db/models.py
index 096c95b..291c08d 100644
--- a/app/db/models.py
+++ b/app/db/models.py
@@ -314,3 +314,11 @@ class NotificationReminder(Base):
type = Column(Enum(ReminderType), nullable=False)
expires_at = Column(DateTime)
created_at = Column(DateTime, default=datetime.utcnow)
+
+
+class Settings(Base):
+ __tablename__ = "settings"
+
+ id = Column(Integer, primary_key=True, server_default=text("0"))
+ subscription = Column(JSON, nullable=False)
+ telegram = Column(JSON)
diff --git a/app/models/settings.py b/app/models/settings.py
new file mode 100644
index 0000000..be4c01e
--- /dev/null
+++ b/app/models/settings.py
@@ -0,0 +1,39 @@
+from enum import Enum
+from typing import Pattern
+
+from pydantic import BaseModel
+
+
+class ConfigTypes(str, Enum):
+ links = "links"
+ base64_links = "base64-links"
+ xray = "xray"
+ sing_box = "sing-box"
+ clash = "clash"
+ clash_meta = "clash-meta"
+ template = "template"
+ block = "block"
+
+
+class SubscriptionRule(BaseModel):
+ pattern: Pattern
+ result: ConfigTypes
+
+
+class SubscriptionSettings(BaseModel):
+ url_prefix: str
+ profile_title: str
+ support_link: str
+ update_interval: int
+ rules: list[SubscriptionRule]
+
+
+class TelegramSettings(BaseModel):
+ token: str
+ admin_id: list[int]
+ channel_id: int | None
+
+
+class Settings(BaseModel):
+ subscription: SubscriptionSettings
+ telegram: TelegramSettings | None
diff --git a/app/routes/subscription.py b/app/routes/subscription.py
index ca993da..bbf3365 100644
--- a/app/routes/subscription.py
+++ b/app/routes/subscription.py
@@ -1,24 +1,39 @@
import re
+from collections import defaultdict
from fastapi import APIRouter
from fastapi import Header, HTTPException, Path, Request, Response
from fastapi.responses import HTMLResponse
from app.db import crud
+from app.db.models import Settings
from app.dependencies import DBDep, SubUserDep, StartDateDep, EndDateDep
+from app.models.settings import SubscriptionSettings
from app.models.user import UserResponse
from app.templates import render_template
from app.utils.share import encode_title, generate_subscription
from config import (
- SUB_PROFILE_TITLE,
- SUB_SUPPORT_URL,
- SUB_UPDATE_INTERVAL,
SUBSCRIPTION_PAGE_TEMPLATE,
)
router = APIRouter(prefix="/sub", tags=["Subscription"])
+config_mimetype = defaultdict(
+ lambda: "text/plain",
+ {
+ "links": "text/plain",
+ "base64-links": "text/plain",
+ "sing-box": "application/json",
+ "xray": "application/json",
+ "clash": "text/yaml",
+ "clash-meta": "text/yaml",
+ "template": "text/html",
+ "block": "text/plain",
+ },
+)
+
+
def get_subscription_user_info(user: UserResponse) -> dict:
return {
"upload": 0,
@@ -38,26 +53,21 @@ def user_subscription(
"""
Subscription link, V2ray and Clash supported
"""
- accept_header = request.headers.get("Accept", "")
user: UserResponse = UserResponse.model_validate(db_user)
- if "text/html" in accept_header:
- links = generate_subscription(
- user=db_user, config_format="v2ray"
- ).split()
- return HTMLResponse(
- render_template(
- SUBSCRIPTION_PAGE_TEMPLATE, {"user": user, "links": links}
- )
- )
+ crud.update_user_sub(db, db_user, user_agent)
+
+ subscription_settings = SubscriptionSettings.model_validate(
+ db.query(Settings.subscription).first()[0]
+ )
response_headers = {
"content-disposition": f'attachment; filename="{user.username}"',
"profile-web-page-url": str(request.url),
- "support-url": SUB_SUPPORT_URL,
- "profile-title": encode_title(SUB_PROFILE_TITLE),
- "profile-update-interval": SUB_UPDATE_INTERVAL,
+ "support-url": subscription_settings.support_link,
+ "profile-title": encode_title(subscription_settings.profile_title),
+ "profile-update-interval": str(subscription_settings.update_interval),
"subscription-userinfo": "; ".join(
f"{key}={val}"
for key, val in get_subscription_user_info(user).items()
@@ -65,32 +75,37 @@ def user_subscription(
),
}
- crud.update_user_sub(db, db_user, user_agent)
-
- if re.match("^([Cc]lash-verge|[Cc]lash-?[Mm]eta)", user_agent):
- conf = generate_subscription(user=db_user, config_format="clash-meta")
- return Response(
- content=conf, media_type="text/yaml", headers=response_headers
- )
- elif re.match("^([Cc]lash|[Ss]tash)", user_agent):
- conf = generate_subscription(user=db_user, config_format="clash")
- return Response(
- content=conf, media_type="text/yaml", headers=response_headers
- )
- elif re.match("^(SFA|SFI|SFM|SFT)", user_agent):
- conf = generate_subscription(user=db_user, config_format="sing-box")
- return Response(
- content=conf,
- media_type="application/json",
- headers=response_headers,
- )
- else:
- conf = generate_subscription(
- user=db_user, config_format="v2ray", as_base64=True
- )
- return Response(
- content=conf, media_type="text/plain", headers=response_headers
- )
+ for rule in subscription_settings.rules:
+ if re.match(rule.pattern, user_agent):
+ if rule.result.value == "template":
+ links = generate_subscription(
+ user=db_user, config_format="links"
+ ).split()
+ return HTMLResponse(
+ render_template(
+ SUBSCRIPTION_PAGE_TEMPLATE,
+ {"user": user, "links": links},
+ )
+ )
+ elif rule.result.value == "block":
+ return HTT
+ elif rule.result.value == "base64-links":
+ b64 = True
+ config_format = "links"
+ else:
+ b64 = False
+ config_format = rule.result.value
+
+ conf = generate_subscription(
+ user=db_user,
+ config_format=config_format,
+ as_base64=b64,
+ )
+ return Response(
+ content=conf,
+ media_type=config_mimetype[rule.result],
+ headers=response_headers,
+ )
@router.get("/{username}/{key}/info", response_model=UserResponse)
@@ -112,6 +127,7 @@ def user_get_usage(
@router.get("/{username}/{key}/{client_type}")
def user_subscription_with_client_type(
+ db: DBDep,
db_user: SubUserDep,
request: Request,
client_type: str = Path(regex="sing-box|clash-meta|clash|xray|v2ray"),
@@ -122,12 +138,16 @@ def user_subscription_with_client_type(
user: UserResponse = UserResponse.model_validate(db_user)
+ subscription_settings = SubscriptionSettings.model_validate(
+ db.query(Settings.subscription).first()[0]
+ )
+
response_headers = {
"content-disposition": f'attachment; filename="{user.username}"',
"profile-web-page-url": str(request.url),
- "support-url": SUB_SUPPORT_URL,
- "profile-title": encode_title(SUB_PROFILE_TITLE),
- "profile-update-interval": SUB_UPDATE_INTERVAL,
+ "support-url": subscription_settings.support_link,
+ "profile-title": encode_title(subscription_settings.profile_title),
+ "profile-update-interval": subscription_settings.update_interval,
"subscription-userinfo": "; ".join(
f"{key}={val}"
for key, val in get_subscription_user_info(user).items()
@@ -156,7 +176,7 @@ def user_subscription_with_client_type(
elif client_type == "v2ray":
conf = generate_subscription(
- user=db_user, config_format="v2ray", as_base64=True
+ user=db_user, config_format="links", as_base64=True
)
return Response(
content=conf, media_type="text/plain", headers=response_headers
@@ -164,6 +184,7 @@ def user_subscription_with_client_type(
elif client_type == "xray":
return Response(
content=generate_subscription(user=db_user, config_format="xray"),
+ media_type="application/json",
headers=response_headers,
)
else:
diff --git a/app/routes/system.py b/app/routes/system.py
index 561cbcb..aee3cf3 100644
--- a/app/routes/system.py
+++ b/app/routes/system.py
@@ -1,17 +1,47 @@
from fastapi import APIRouter
-from app import __version__
from app.db import crud
-from app.db.models import Admin as DBAdmin, User
+from app.db.models import Admin as DBAdmin, User, Settings
from app.db.models import Node
from app.dependencies import DBDep, AdminDep, SudoAdminDep
from app.models.node import NodeStatus
-from app.models.system import SystemStats, UsersStats, NodesStats, AdminsStats
+from app.models.settings import SubscriptionSettings, TelegramSettings
+from app.models.system import UsersStats, NodesStats, AdminsStats
from app.models.user import UserStatus
router = APIRouter(tags=["System"], prefix="/system")
+@router.get("/settings/subscription", response_model=SubscriptionSettings)
+def get_subscription_settings(db: DBDep, admin: SudoAdminDep):
+ return db.query(Settings.subscription).first()[0]
+
+
+@router.put("/settings/subscription", response_model=SubscriptionSettings)
+def update_subscription_settings(
+ db: DBDep, modifications: SubscriptionSettings, admin: SudoAdminDep
+):
+ settings = db.query(Settings).first()
+ settings.subscription = modifications.model_dump(mode="json")
+ db.commit()
+ return settings.subscription
+
+
+@router.get("/settings/telegram", response_model=TelegramSettings | None)
+def get_telegram_settings(db: DBDep, admin: SudoAdminDep):
+ return db.query(Settings.telegram).first().telegram
+
+
+@router.put("/settings/telegram", response_model=TelegramSettings | None)
+def update_telegram_settings(
+ db: DBDep, new_telegram: TelegramSettings | None, admin: SudoAdminDep
+):
+ settings = db.query(Settings.telegram).first()
+ settings.telegram = new_telegram
+ db.commit()
+ return settings.telegram
+
+
@router.get("/stats/admins", response_model=AdminsStats)
def get_admins_stats(db: DBDep, admin: SudoAdminDep):
return AdminsStats(total=db.query(DBAdmin).count())
diff --git a/app/utils/share.py b/app/utils/share.py
index 2e6d6b0..8522186 100644
--- a/app/utils/share.py
+++ b/app/utils/share.py
@@ -43,7 +43,7 @@ STATUS_EMOJIS = {
}
subscription_handlers = {
- "v2ray": LinksConfig,
+ "links": LinksConfig,
"xray": XrayConfig,
"clash-meta": ClashMetaConfig,
"clash": ClashConfig,
@@ -63,7 +63,7 @@ handlers_templates = {
def generate_subscription(
user: "UserResponse",
- config_format: Literal["v2ray", "xray", "clash-meta", "clash", "sing-box"],
+ config_format: Literal["links", "xray", "clash-meta", "clash", "sing-box"],
as_base64: bool = False,
) -> str:
diff --git a/dashboard/package.json b/dashboard/package.json
index c39f670..c298722 100644
--- a/dashboard/package.json
+++ b/dashboard/package.json
@@ -21,6 +21,10 @@
"dependencies": {
"@chbphone55/pretty-bytes": "^1.0.0",
"@codemirror/language": "^6.10.2",
+ "@dnd-kit/core": "^6.1.0",
+ "@dnd-kit/modifiers": "^7.0.0",
+ "@dnd-kit/sortable": "^8.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"@heroicons/react": "^2.1.3",
"@hookform/resolvers": "^3.4.2",
"@kodingdotninja/use-tailwind-breakpoint": "^0.0.5",
diff --git a/dashboard/pnpm-lock.yaml b/dashboard/pnpm-lock.yaml
index e3fbd09..ff94f22 100644
--- a/dashboard/pnpm-lock.yaml
+++ b/dashboard/pnpm-lock.yaml
@@ -11,6 +11,18 @@ dependencies:
'@codemirror/language':
specifier: ^6.10.2
version: 6.10.2
+ '@dnd-kit/core':
+ specifier: ^6.1.0
+ version: 6.1.0(react-dom@18.3.1)(react@18.3.1)
+ '@dnd-kit/modifiers':
+ specifier: ^7.0.0
+ version: 7.0.0(@dnd-kit/core@6.1.0)(react@18.3.1)
+ '@dnd-kit/sortable':
+ specifier: ^8.0.0
+ version: 8.0.0(@dnd-kit/core@6.1.0)(react@18.3.1)
+ '@dnd-kit/utilities':
+ specifier: ^3.2.2
+ version: 3.2.2(react@18.3.1)
'@heroicons/react':
specifier: ^2.1.3
version: 2.1.3(react@18.3.1)
@@ -1717,6 +1729,61 @@ packages:
engines: {node: '>=10.0.0'}
dev: true
+ /@dnd-kit/accessibility@3.1.0(react@18.3.1):
+ resolution: {integrity: sha512-ea7IkhKvlJUv9iSHJOnxinBcoOI3ppGnnL+VDJ75O45Nss6HtZd8IdN8touXPDtASfeI2T2LImb8VOZcL47wjQ==}
+ peerDependencies:
+ react: '>=16.8.0'
+ dependencies:
+ react: 18.3.1
+ tslib: 2.6.2
+ dev: false
+
+ /@dnd-kit/core@6.1.0(react-dom@18.3.1)(react@18.3.1):
+ resolution: {integrity: sha512-J3cQBClB4TVxwGo3KEjssGEXNJqGVWx17aRTZ1ob0FliR5IjYgTxl5YJbKTzA6IzrtelotH19v6y7uoIRUZPSg==}
+ peerDependencies:
+ react: '>=16.8.0'
+ react-dom: '>=16.8.0'
+ dependencies:
+ '@dnd-kit/accessibility': 3.1.0(react@18.3.1)
+ '@dnd-kit/utilities': 3.2.2(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ tslib: 2.6.2
+ dev: false
+
+ /@dnd-kit/modifiers@7.0.0(@dnd-kit/core@6.1.0)(react@18.3.1):
+ resolution: {integrity: sha512-BG/ETy3eBjFap7+zIti53f0PCLGDzNXyTmn6fSdrudORf+OH04MxrW4p5+mPu4mgMk9kM41iYONjc3DOUWTcfg==}
+ peerDependencies:
+ '@dnd-kit/core': ^6.1.0
+ react: '>=16.8.0'
+ dependencies:
+ '@dnd-kit/core': 6.1.0(react-dom@18.3.1)(react@18.3.1)
+ '@dnd-kit/utilities': 3.2.2(react@18.3.1)
+ react: 18.3.1
+ tslib: 2.6.2
+ dev: false
+
+ /@dnd-kit/sortable@8.0.0(@dnd-kit/core@6.1.0)(react@18.3.1):
+ resolution: {integrity: sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==}
+ peerDependencies:
+ '@dnd-kit/core': ^6.1.0
+ react: '>=16.8.0'
+ dependencies:
+ '@dnd-kit/core': 6.1.0(react-dom@18.3.1)(react@18.3.1)
+ '@dnd-kit/utilities': 3.2.2(react@18.3.1)
+ react: 18.3.1
+ tslib: 2.6.2
+ dev: false
+
+ /@dnd-kit/utilities@3.2.2(react@18.3.1):
+ resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
+ peerDependencies:
+ react: '>=16.8.0'
+ dependencies:
+ react: 18.3.1
+ tslib: 2.6.2
+ dev: false
+
/@emotion/babel-plugin@11.11.0:
resolution: {integrity: sha512-m4HEDZleaaCH+XgDDsPF15Ht6wTLsgDTeR3WYj9Q/k76JtWhrJjcP4+/XlG8LGT/Rol9qUfOIztXeA84ATpqPQ==}
dependencies:
diff --git a/dashboard/public/locales/en.json b/dashboard/public/locales/en.json
index 7fb1fea..a41a8a2 100644
--- a/dashboard/public/locales/en.json
+++ b/dashboard/public/locales/en.json
@@ -51,6 +51,8 @@
"advanced-options": "Advanced Options",
"language": "Language",
"theme": "Theme",
+ "pattern": "Pattern",
+ "result": "Result",
"support-us": {
"title": "Support Us",
"desc": "Marzneshin is free and open-source software (FOSS), join us and show your support by donating.",
@@ -58,6 +60,20 @@
},
"page": {
"settings": {
+ "subscription-settings": {
+ "url-prefix": "URL Prefix",
+ "profile-title": "Profile Title",
+ "support-link": "Support Link",
+ "update-interval": "Update Interval",
+ "add-rule": "Add Rule",
+ "title": "Subscription Settings",
+ "subscription-title": "Subscription Rules",
+ "subscription-desc": "Add your subscription rules in order of priority.",
+ "alert": {
+ "title": "No rules",
+ "desc": "No rules have been defined for subscription"
+ }
+ },
"certificate": {
"copy": "Copy Certificate",
"error": "Certificate Unavailable",
diff --git a/dashboard/src/components/ui/index.ts b/dashboard/src/components/ui/index.ts
index 4a47b94..b66074a 100644
--- a/dashboard/src/components/ui/index.ts
+++ b/dashboard/src/components/ui/index.ts
@@ -33,3 +33,5 @@ export * from "./textarea";
export * from "./radio-group";
export * from "./accordion";
export * from "./command";
+export * from "./sortable";
+export * from "./skeleton";
diff --git a/dashboard/src/components/ui/skeleton.tsx b/dashboard/src/components/ui/skeleton.tsx
new file mode 100644
index 0000000..72f91f5
--- /dev/null
+++ b/dashboard/src/components/ui/skeleton.tsx
@@ -0,0 +1,15 @@
+import { cn } from "@marzneshin/utils"
+
+function Skeleton({
+ className,
+ ...props
+}: React.HTMLAttributes<HTMLDivElement>) {
+ return (
+ <div
+ className={cn("animate-pulse rounded-md bg-muted", className)}
+ {...props}
+ />
+ )
+}
+
+export { Skeleton }
diff --git a/dashboard/src/components/ui/sortable/context.ts b/dashboard/src/components/ui/sortable/context.ts
new file mode 100644
index 0000000..0836f76
--- /dev/null
+++ b/dashboard/src/components/ui/sortable/context.ts
@@ -0,0 +1,14 @@
+import type {
+ DraggableSyntheticListeners,
+} from "@dnd-kit/core"
+import React from "react";
+
+interface SortableItemContextProps {
+ attributes: React.HTMLAttributes<HTMLElement>
+ listeners: DraggableSyntheticListeners | undefined
+}
+
+export const SortableItemContext = React.createContext<SortableItemContextProps>({
+ attributes: {},
+ listeners: undefined,
+})
diff --git a/dashboard/src/components/ui/sortable/drag-handle.tsx b/dashboard/src/components/ui/sortable/drag-handle.tsx
new file mode 100644
index 0000000..1b9e15b
--- /dev/null
+++ b/dashboard/src/components/ui/sortable/drag-handle.tsx
@@ -0,0 +1,26 @@
+import React from "react";
+import { Button, type ButtonProps } from "@marzneshin/components"
+import { useSortableItem } from "./use-sortable-item";
+import { composeRefs, cn } from "@marzneshin/utils";
+
+interface SortableDragHandleProps extends ButtonProps {
+ withHandle?: boolean
+}
+
+export const SortableDragHandle = React.forwardRef<
+ HTMLButtonElement,
+ SortableDragHandleProps
+>(({ className, ...props }, ref) => {
+ const { attributes, listeners } = useSortableItem()
+
+ return (
+ <Button
+ ref={composeRefs(ref)}
+ className={cn("cursor-grab", className)}
+ {...attributes}
+ {...listeners}
+ {...props}
+ />
+ )
+})
+SortableDragHandle.displayName = "SortableDragHandle"
diff --git a/dashboard/src/components/ui/sortable/index.tsx b/dashboard/src/components/ui/sortable/index.tsx
new file mode 100644
index 0000000..f7e0d0a
--- /dev/null
+++ b/dashboard/src/components/ui/sortable/index.tsx
@@ -0,0 +1,144 @@
+import React from "react"
+import type {
+ DndContextProps,
+ UniqueIdentifier,
+} from "@dnd-kit/core";
+import {
+ closestCenter,
+ DndContext,
+ KeyboardSensor,
+ MouseSensor,
+ TouchSensor,
+ useSensor,
+ useSensors,
+} from "@dnd-kit/core";
+import {
+ restrictToParentElement,
+ restrictToVerticalAxis,
+} from "@dnd-kit/modifiers";
+import {
+ arrayMove,
+ SortableContext,
+ verticalListSortingStrategy,
+ type SortableContextProps,
+} from "@dnd-kit/sortable";
+import { SortableDragHandle } from "./drag-handle";
+import { SortableItem } from "./item";
+import { SortableOverlay } from "./overlay";
+
+interface SortableProps<TData extends { id: UniqueIdentifier }>
+ extends DndContextProps {
+ /**
+ * An array of data items that the sortable component will render.
+ * @example
+ * value={[
+ * { id: 1, name: 'Item 1' },
+ * { id: 2, name: 'Item 2' },
+ * ]}
+ */
+ value: TData[]
+
+ /**
+ * An optional callback function that is called when the order of the data items changes.
+ * It receives the new array of items as its argument.
+ * @example
+ * onValueChange={(items) => console.log(items)}
+ */
+ onValueChange?: (items: TData[]) => void
+
+ /**
+ * An optional callback function that is called when an item is moved.
+ * It receives an event object with `activeIndex` and `overIndex` properties, representing the original and new positions of the moved item.
+ * This will override the default behavior of updating the order of the data items.
+ * @type (event: { activeIndex: number; overIndex: number }) => void
+ * @example
+ * onMove={(event) => console.log(`Item moved from index ${event.activeIndex} to index ${event.overIndex}`)}
+ */
+ onMove?: (event: { activeIndex: number; overIndex: number }) => void
+
+ /**
+ * A collision detection strategy that will be used to determine the closest sortable item.
+ * @default closestCenter
+ * @type DndContextProps["collisionDetection"]
+ */
+ collisionDetection?: DndContextProps["collisionDetection"]
+
+ /**
+ * An array of modifiers that will be used to modify the behavior of the sortable component.
+ * @default
+ * [restrictToVerticalAxis, restrictToParentElement]
+ * @type Modifier[]
+ */
+ modifiers?: DndContextProps["modifiers"]
+
+ /**
+ * A sorting strategy that will be used to determine the new order of the data items.
+ * @default verticalListSortingStrategy
+ * @type SortableContextProps["strategy"]
+ */
+ strategy?: SortableContextProps["strategy"]
+
+ /**
+ * An optional React node that is rendered on top of the sortable component.
+ * It can be used to display additional information or controls.
+ * @default null
+ * @type React.ReactNode | null
+ * @example
+ * overlay={<Skeleton className="w-full h-8" />}
+ */
+ overlay?: React.ReactNode | null
+}
+
+function Sortable<TData extends { id: UniqueIdentifier }>({
+ value,
+ onValueChange,
+ collisionDetection = closestCenter,
+ modifiers = [restrictToVerticalAxis, restrictToParentElement],
+ strategy = verticalListSortingStrategy,
+ onMove,
+ children,
+ overlay,
+ ...props
+}: SortableProps<TData>) {
+ const [activeId, setActiveId] = React.useState<UniqueIdentifier | null>(null)
+
+ const sensors = useSensors(
+ useSensor(MouseSensor),
+ useSensor(TouchSensor),
+ useSensor(KeyboardSensor)
+ )
+
+ return (
+ <DndContext
+ modifiers={modifiers}
+ sensors={sensors}
+ onDragStart={({ active }) => setActiveId(active.id)}
+ onDragEnd={({ active, over }) => {
+ if (over && active.id !== over?.id) {
+ const activeIndex = value.findIndex((item) => item.id === active.id)
+ const overIndex = value.findIndex((item) => item.id === over.id)
+
+ if (onMove) {
+ onMove({ activeIndex, overIndex })
+ } else {
+ onValueChange?.(arrayMove(value, activeIndex, overIndex))
+ }
+ }
+ setActiveId(null)
+ }}
+ onDragCancel={() => setActiveId(null)}
+ collisionDetection={collisionDetection}
+ {...props}
+ >
+ <SortableContext items={value} strategy={strategy}>
+ {children}
+ </SortableContext>
+ {overlay ? (
+ <SortableOverlay activeId={activeId}>{overlay}</SortableOverlay>
+ ) : null}
+ </DndContext>
+ )
+}
+
+
+export { Sortable, SortableDragHandle, SortableItem, SortableOverlay }
diff --git a/dashboard/src/components/ui/sortable/item.tsx b/dashboard/src/components/ui/sortable/item.tsx
new file mode 100644
index 0000000..807f2aa
--- /dev/null
+++ b/dashboard/src/components/ui/sortable/item.tsx
@@ -0,0 +1,55 @@
+import React from "react"
+import { CSS } from "@dnd-kit/utilities"
+import { Slot, type SlotProps } from "@radix-ui/react-slot"
+import { useSortable } from "@dnd-kit/sortable"
+import { composeRefs, cn } from "@marzneshin/utils"
+import type {
+ UniqueIdentifier,
+} from "@dnd-kit/core"
+
+import { SortableItemContext } from "./context"
+
+interface SortableItemProps extends SlotProps {
+ value: UniqueIdentifier
+ asChild?: boolean
+}
+
+export const SortableItem = React.forwardRef<HTMLDivElement, SortableItemProps>(
+ ({ asChild, className, value, ...props }, ref) => {
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({ id: value })
+
+ const context = React.useMemo(
+ () => ({
+ attributes,
+ listeners,
+ }),
+ [attributes, listeners]
+ )
+ const style: React.CSSProperties = {
+ opacity: isDragging ? 0.4 : undefined,
+ transform: CSS.Translate.toString(transform),
+ transition,
+ }
+
+ const Comp = asChild ? Slot : "div"
+
+ return (
+ <SortableItemContext.Provider value={context}>
+ <Comp
+ className={cn(isDragging && "cursor-grabbing", className)}
+ ref={composeRefs(ref, setNodeRef as React.Ref<HTMLDivElement>)}
+ style={style}
+ {...props}
+ />
+ </SortableItemContext.Provider>
+ )
+ }
+)
+SortableItem.displayName = "SortableItem"
diff --git a/dashboard/src/components/ui/sortable/overlay.tsx b/dashboard/src/components/ui/sortable/overlay.tsx
new file mode 100644
index 0000000..ebc8ff3
--- /dev/null
+++ b/dashboard/src/components/ui/sortable/overlay.tsx
@@ -0,0 +1,40 @@
+
+import {
+ type DropAnimation,
+ type UniqueIdentifier,
+ defaultDropAnimationSideEffects,
+ DragOverlay,
+} from "@dnd-kit/core";
+import { SortableItem} from "./item";
+
+const dropAnimationOpts: DropAnimation = {
+ sideEffects: defaultDropAnimationSideEffects({
+ styles: {
+ active: {
+ opacity: "0.4",
+ },
+ },
+ }),
+}
+interface SortableOverlayProps
+ extends React.ComponentPropsWithRef<typeof DragOverlay> {
+ activeId?: UniqueIdentifier | null
+}
+
+export function SortableOverlay({
+ activeId,
+ dropAnimation = dropAnimationOpts,
+ children,
+ ...props
+}: SortableOverlayProps) {
+ return (
+ <DragOverlay dropAnimation={dropAnimation} {...props}>
+ {activeId ? (
+ <SortableItem value={activeId} asChild>
+ {children}
+ </SortableItem>
+ ) : null}
+ </DragOverlay>
+ )
+}
+
diff --git a/dashboard/src/components/ui/sortable/use-sortable-item.ts b/dashboard/src/components/ui/sortable/use-sortable-item.ts
new file mode 100644
index 0000000..0154b85
--- /dev/null
+++ b/dashboard/src/components/ui/sortable/use-sortable-item.ts
@@ -0,0 +1,12 @@
+import React from "react";
+import { SortableItemContext} from "./context";
+
+export function useSortableItem() {
+ const context = React.useContext(SortableItemContext)
+
+ if (!context) {
+ throw new Error("useSortableItem must be used within a SortableItem")
+ }
+
+ return context
+}
diff --git a/dashboard/src/components/widgets/index.ts b/dashboard/src/components/widgets/index.ts
index 0b11f2d..ce36325 100644
--- a/dashboard/src/components/widgets/index.ts
+++ b/dashboard/src/components/widgets/index.ts
@@ -1 +1,2 @@
export * from './mini-widget'
+export * from './section-widget'
diff --git a/dashboard/src/components/widgets/section-widget/index.tsx b/dashboard/src/components/widgets/section-widget/index.tsx
new file mode 100644
index 0000000..d7684e0
--- /dev/null
+++ b/dashboard/src/components/widgets/section-widget/index.tsx
@@ -0,0 +1,32 @@
+import {
+ Card,
+ CardContent,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+ Separator,
+} from '@marzneshin/components';
+import type { FC, PropsWithChildren } from 'react';
+
+export interface SectionWidgetProps extends PropsWithChildren {
+ title: JSX.Element | string;
+ content?: JSX.Element | string;
+ footer?: JSX.Element | string;
+}
+
+export const SectionWidget: FC<SectionWidgetProps> = ({ footer, content, children, title }) => {
+ return (
+ <Card>
+ <CardHeader className="p-4">
+ <CardTitle className="flex flex-row justify-start items-center gap-3 text-xl">
+ {title}
+ </CardTitle>
+ </CardHeader>
+ <Separator />
+ <CardContent className="p-4">
+ {content || children}
+ </CardContent>
+ {footer && <CardFooter> {footer} </CardFooter>}
+ </Card>
+ )
+}
diff --git a/dashboard/src/features/settings/index.ts b/dashboard/src/features/settings/index.ts
index e3c54d6..9b25af8 100644
--- a/dashboard/src/features/settings/index.ts
+++ b/dashboard/src/features/settings/index.ts
@@ -1,2 +1,3 @@
export * from './certificate-button'
export * from './services'
+export * from "./widgets"
diff --git a/dashboard/src/features/settings/widgets/certificate.tsx b/dashboard/src/features/settings/widgets/certificate.tsx
new file mode 100644
index 0000000..1da2617
--- /dev/null
+++ b/dashboard/src/features/settings/widgets/certificate.tsx
@@ -0,0 +1,16 @@
+import {
+ MiniWidget
+} from "@marzneshin/components";
+import {
+ CertificateButton
+} from "@marzneshin/features/settings";
+
+export const CertificateWidget = () => {
+ return (
+ <MiniWidget
+ title="Ceritifcate"
+ >
+ <CertificateButton />
+ </MiniWidget>
+ )
+}
diff --git a/dashboard/src/features/settings/widgets/configuration.tsx b/dashboard/src/features/settings/widgets/configuration.tsx
new file mode 100644
index 0000000..8c55651
--- /dev/null
+++ b/dashboard/src/features/settings/widgets/configuration.tsx
@@ -0,0 +1,10 @@
+
+import { SectionWidget } from "@marzneshin/components";
+
+export const ConfigurationWidget = () => {
+ return (
+ <SectionWidget title="System Configuration">
+ Config fields
+ </SectionWidget>
+ )
+}
diff --git a/dashboard/src/features/settings/widgets/index.ts b/dashboard/src/features/settings/widgets/index.ts
new file mode 100644
index 0000000..b80e30f
--- /dev/null
+++ b/dashboard/src/features/settings/widgets/index.ts
@@ -0,0 +1,2 @@
+export * from "./certificate"
+export * from "./configuration"
diff --git a/dashboard/src/features/subscription-settings/form/fields/index.ts b/dashboard/src/features/subscription-settings/form/fields/index.ts
new file mode 100644
index 0000000..be58c96
--- /dev/null
+++ b/dashboard/src/features/subscription-settings/form/fields/index.ts
@@ -0,0 +1,4 @@
+export * from "./url-prefix"
+export * from "./profile-title"
+export * from "./support-link"
+export * from "./update-interval"
diff --git a/dashboard/src/features/subscription-settings/form/fields/profile-title.tsx b/dashboard/src/features/subscription-settings/form/fields/profile-title.tsx
new file mode 100644
index 0000000..17e05bc
--- /dev/null
+++ b/dashboard/src/features/subscription-settings/form/fields/profile-title.tsx
@@ -0,0 +1,28 @@
+import {
+ FormField,
+ FormItem,
+ FormLabel,
+ Input,
+ FormControl,
+} from "@marzneshin/components";
+import { useFormContext } from "react-hook-form";
+import { useTranslation } from "react-i18next";
+
+export const ProfileTitleField = () => {
+ const { t } = useTranslation()
+ const form = useFormContext();
+ return (
+ <FormField
+ control={form.control}
+ name="profile_title"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("page.settings.subscription-settings.profile-title")}</FormLabel>
+ <FormControl>
+ <Input className="h-8" {...field} />
+ </FormControl>
+ </FormItem>
+ )}
+ />
+ )
+}
diff --git a/dashboard/src/features/subscription-settings/form/fields/support-link.tsx b/dashboard/src/features/subscription-settings/form/fields/support-link.tsx
new file mode 100644
index 0000000..b116670
--- /dev/null
+++ b/dashboard/src/features/subscription-settings/form/fields/support-link.tsx
@@ -0,0 +1,28 @@
+import {
+ FormField,
+ FormItem,
+ FormLabel,
+ Input,
+ FormControl,
+} from "@marzneshin/components";
+import { useFormContext } from "react-hook-form";
+import { useTranslation } from "react-i18next";
+
+export const SupportLinkField = () => {
+ const { t } = useTranslation()
+ const form = useFormContext();
+ return (
+ <FormField
+ control={form.control}
+ name="support_link"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("page.settings.subscription-settings.support-link")}</FormLabel>
+ <FormControl>
+ <Input className="h-8" {...field} />
+ </FormControl>
+ </FormItem>
+ )}
+ />
+ )
+}
diff --git a/dashboard/src/features/subscription-settings/form/fields/update-interval.tsx b/dashboard/src/features/subscription-settings/form/fields/update-interval.tsx
new file mode 100644
index 0000000..37880ee
--- /dev/null
+++ b/dashboard/src/features/subscription-settings/form/fields/update-interval.tsx
@@ -0,0 +1,33 @@
+import {
+ FormField,
+ FormItem,
+ FormLabel,
+ Input,
+ FormControl,
+} from "@marzneshin/components";
+import { useFormContext } from "react-hook-form";
+import { useTranslation } from "react-i18next";
+
+export const UpdateIntervalField = () => {
+ const { t } = useTranslation()
+ const form = useFormContext();
+ return (
+ <FormField
+ control={form.control}
+ name="update_interval"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("page.settings.subscription-settings.update-interval")}</FormLabel>
+ <FormControl>
+ <Input
+ className="h-8"
+ type="number"
+ {...field}
+ {...form.register('update_interval', { valueAsNumber: true })}
+ />
+ </FormControl>
+ </FormItem>
+ )}
+ />
+ )
+}
diff --git a/dashboard/src/features/subscription-settings/form/fields/url-prefix.tsx b/dashboard/src/features/subscription-settings/form/fields/url-prefix.tsx
new file mode 100644
index 0000000..fdc17de
--- /dev/null
+++ b/dashboard/src/features/subscription-settings/form/fields/url-prefix.tsx
@@ -0,0 +1,28 @@
+import {
+ FormField,
+ FormItem,
+ FormLabel,
+ Input,
+ FormControl,
+} from "@marzneshin/components";
+import { useFormContext } from "react-hook-form";
+import { useTranslation } from "react-i18next";
+
+export const UrlPrefixField = () => {
+ const { t } = useTranslation()
+ const form = useFormContext();
+ return (
+ <FormField
+ control={form.control}
+ name="url_prefix"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("page.settings.subscription-settings.url-prefix")}</FormLabel>
+ <FormControl>
+ <Input className="h-8" {...field} />
+ </FormControl>
+ </FormItem>
+ )}
+ />
+ )
+}
diff --git a/dashboard/src/features/subscription-settings/form/form.tsx b/dashboard/src/features/subscription-settings/form/form.tsx
new file mode 100644
index 0000000..99d5e86
--- /dev/null
+++ b/dashboard/src/features/subscription-settings/form/form.tsx
@@ -0,0 +1,126 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useFieldArray, useForm } from "react-hook-form";
+import { useTranslation } from "react-i18next";
+import { schema } from "./schema";
+import { RuleItem } from "./rule-item";
+import {
+ HStack,
+ Button,
+ Form,
+ Sortable,
+ ScrollArea,
+ FormLabel,
+} from "@marzneshin/components";
+import { Schema } from "./schema"
+import {
+ UrlPrefixField,
+ ProfileTitleField,
+ SupportLinkField,
+ UpdateIntervalField
+} from "./fields";
+import { NoRulesAlert } from "./no-rules-alert";
+import {
+ useSubscriptionSettingsQuery,
+ useSubscriptionSettingsMutation,
+} from "@marzneshin/features/subscription-settings";
+import { useEffect, useCallback } from "react";
+
+export function SubscriptionRulesForm() {
+ const { t } = useTranslation()
+ const { data } = useSubscriptionSettingsQuery()
+ const subscriptionSettings = useSubscriptionSettingsMutation()
+ const form = useForm<Schema>({
+ resolver: zodResolver(schema),
+ defaultValues: data,
+ })
+
+ const handleSubscriptionRulesDataUpdate = useCallback(
+ () => {
+ form.reset(data, {
+ keepDirtyValues: true
+ })
+ },
+ [form,data],
+ )
+
+ useEffect(() => {
+ if (data) {
+ handleSubscriptionRulesDataUpdate()
+ }
+ }, [data, handleSubscriptionRulesDataUpdate])
+
+ const onSubmit = (data: Schema) => {
+ subscriptionSettings.mutate(data)
+ }
+
+ const { fields, append, move, remove } = useFieldArray({
+ control: form.control,
+ name: "rules"
+ })
+
+ return (
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="flex w-full max-w-4xl flex-col gap-2"
+ >
+ <div className="md:grid grid-cols-2 gap-2 flex flex-col">
+ <UrlPrefixField />
+ <ProfileTitleField />
+ <SupportLinkField />
+ <UpdateIntervalField />
+ </div>
+ <h4 className="text-lg mt-2">
+ {t("page.settings.subscription-settings.subscription-title")}
+ </h4>
+ <p className="text-sm text-muted-foreground">
+ {t("page.settings.subscription-settings.subscription-desc")}
+ </p>
+ {fields.length === 0 && <NoRulesAlert />}
+ <div className="space-y-1">
+ <Sortable
+ value={fields}
+ onMove={({ activeIndex, overIndex }) =>
+ move(activeIndex, overIndex)
+ }
+ >
+ <ScrollArea className="flex flex-col w-full max-h-[400px] gap-2" type="always">
+ <div className="grid grid-cols-[2fr,1.3fr,0.25fr,0.25fr] items-center justify-start gap-2 my-2">
+ <FormLabel>
+ {t("pattern")}
+ </FormLabel>
+ <FormLabel>
+ {t("result")}
+ </FormLabel>
+ </div>
+ {fields.map((field, index) => (
+ <RuleItem
+ field={field}
+ index={index}
+ onRemove={remove} />
+ ))}
+ </ScrollArea>
+ </Sortable>
+ </div>
+ <HStack className="w-full flex-end">
+ <Button
+ type="button"
+ variant="outline"
+ className="w-fit"
+ onClick={() =>
+ append({
+ pattern: "//",
+ result: "block",
+ })
+ }
+ >
+ {t("page.settings.subscription-settings.add-rule")}
+ </Button>
+ <Button type="submit" className="w-fit">
+ {t("submit")}
+ </Button>
+ </HStack>
+ </form>
+ </Form>
+ )
+}
diff --git a/dashboard/src/features/subscription-settings/form/index.ts b/dashboard/src/features/subscription-settings/form/index.ts
new file mode 100644
index 0000000..883c95b
--- /dev/null
+++ b/dashboard/src/features/subscription-settings/form/index.ts
@@ -0,0 +1,3 @@
+export * from "./schema"
+export * from "./rule-item"
+export * from "./form"
diff --git a/dashboard/src/features/subscription-settings/form/no-rules-alert.tsx b/dashboard/src/features/subscription-settings/form/no-rules-alert.tsx
new file mode 100644
index 0000000..3c74e6f
--- /dev/null
+++ b/dashboard/src/features/subscription-settings/form/no-rules-alert.tsx
@@ -0,0 +1,20 @@
+import {
+ Alert,
+ AlertDescription,
+ AlertTitle,
+} from "@marzneshin/components";
+import { Info } from 'lucide-react';
+import { useTranslation } from "react-i18next";
+
+export const NoRulesAlert = () => {
+ const { t } = useTranslation()
+ return (
+ <Alert>
+ <Info className="mr-2" />
+ <AlertTitle className="font-semibold text-primary">{t('page.settings.subscription-settings.alert.title')}</AlertTitle>
+ <AlertDescription>
+ {t('page.settings.subscription-settings.alert.desc')}
+ </AlertDescription>
+ </Alert>
+ )
+}
diff --git a/dashboard/src/features/subscription-settings/form/overlay.tsx b/dashboard/src/features/subscription-settings/form/overlay.tsx
new file mode 100644
index 0000000..b13c339
--- /dev/null
+++ b/dashboard/src/features/subscription-settings/form/overlay.tsx
@@ -0,0 +1,12 @@
+import {
+ Skeleton,
+} from "@marzneshin/components";
+
+export const Overlay = () => (
+ <div className="grid grid-cols-[0.67fr,0.3fr,auto,auto] items-center justify-start gap-2 my-2 ">
+ <Skeleton className="h-8 w-full rounded-sm" />
+ <Skeleton className="h-8 w-full rounded-sm" />
+ <Skeleton className="size-8 shrink-0 rounded-sm" />
+ <Skeleton className="size-8 shrink-0 rounded-sm" />
+ </div>
+)
diff --git a/dashboard/src/features/subscription-settings/form/rule-item.tsx b/dashboard/src/features/subscription-settings/form/rule-item.tsx
new file mode 100644
index 0000000..917b5c7
--- /dev/null
+++ b/dashboard/src/features/subscription-settings/form/rule-item.tsx
@@ -0,0 +1,103 @@
+import {
+ Button,
+ FormControl,
+ FormField,
+ FormItem,
+ Input,
+ SortableDragHandle,
+ SortableItem,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@marzneshin/components";
+import {
+ Schema
+} from "./schema"
+import {
+ useFormContext,
+ FieldArrayWithId,
+ UseFieldArrayRemove
+} from "react-hook-form";
+import { DragHandleDots2Icon, TrashIcon } from "@radix-ui/react-icons"
+
+interface RuleItemProps {
+ index: number;
+ field: FieldArrayWithId<Schema, "rules">;
+ onRemove: UseFieldArrayRemove;
+}
+
+export const RuleItem = ({ index, field, onRemove: remove }: RuleItemProps) => {
+ const form = useFormContext();
+ return (
+ <SortableItem key={field.id} value={field.id} asChild>
+ <div className="grid grid-cols-[2fr,1.3fr,0.25fr,0.25fr] items-center justify-start gap-2 my-2">
+ <FormField
+ control={form.control}
+ name={`rules.${index}.pattern`}
+ render={({ field }) => (
+ <FormItem>
+ <FormControl>
+ <div>
+ <Input className="h-8" {...field} />
+ </div>
+ </FormControl>
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name={`rules.${index}.result`}
+ render={({ field }) => (
+ <FormItem>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <div>
+ <SelectTrigger className="h-8 p-2">
+ <SelectValue />
+ </SelectTrigger>
+ </div>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="xray">xray</SelectItem>
+ <SelectItem value="v2ray">v2ray</SelectItem>
+ <SelectItem value="sing-box">sing-box</SelectItem>
+ <SelectItem value="clash">clash</SelectItem>
+ <SelectItem value="clash-meta">clash-meta</SelectItem>
+ <SelectItem value="block">block</SelectItem>
+ <SelectItem value="template">template</SelectItem>
+ <SelectItem value="base64-links">base64-links</SelectItem>
+ <SelectItem value="links">links</SelectItem>
+ </SelectContent>
+ </Select>
+ </FormItem>
+ )}
+ />
+ <SortableDragHandle
+ variant="outline"
+ size="icon"
+ className="size-8 shrink-0"
+ >
+ <DragHandleDots2Icon
+ className="size-4"
+ aria-hidden="true"
+ />
+ </SortableDragHandle>
+ <Button
+ type="button"
+ variant="outline"
+ size="icon"
+ className="size-8 shrink-0"
+ onClick={() => remove(index)}
+ >
+ <TrashIcon
+ className="size-4 text-destructive"
+ aria-hidden="true"
+ />
+ <span className="sr-only">Remove</span>
+ </Button>
+ </div>
+ </SortableItem>
+ )
+}
diff --git a/dashboard/src/features/subscription-settings/form/schema.ts b/dashboard/src/features/subscription-settings/form/schema.ts
new file mode 100644
index 0000000..882578d
--- /dev/null
+++ b/dashboard/src/features/subscription-settings/form/schema.ts
@@ -0,0 +1,17 @@
+import { z } from "zod";
+
+
+export const schema = z.object({
+ rules: z.array(
+ z.object({
+ pattern: z.string(),
+ result: z.enum(["xray", "v2ray", "sing-box", "clash", "clash-meta", "block", "links", "base64-links", "template"]),
+ })
+ ),
+ url_prefix: z.string().default(""),
+ profile_title: z.string().default(""),
+ support_link: z.string().default(""),
+ update_interval: z.coerce.number().int().default(0),
+})
+
+export type Schema = z.infer<typeof schema>
diff --git a/dashboard/src/features/subscription-settings/index.ts b/dashboard/src/features/subscription-settings/index.ts
new file mode 100644
index 0000000..2000bd0
--- /dev/null
+++ b/dashboard/src/features/subscription-settings/index.ts
@@ -0,0 +1,4 @@
+export * from "./form"
+export * from "./services"
+export * from "./subscription-rule.type"
+export * from "./widget"
diff --git a/dashboard/src/features/subscription-settings/services/index.ts b/dashboard/src/features/subscription-settings/services/index.ts
new file mode 100644
index 0000000..3a16e0a
--- /dev/null
+++ b/dashboard/src/features/subscription-settings/services/index.ts
@@ -0,0 +1,2 @@
+export * from "./subscription-settings.query"
+export * from "./subscription-settings.mutate"
diff --git a/dashboard/src/features/subscription-settings/services/subscription-settings.mutate.ts b/dashboard/src/features/subscription-settings/services/subscription-settings.mutate.ts
new file mode 100644
index 0000000..8d2c2ad
--- /dev/null
+++ b/dashboard/src/features/subscription-settings/services/subscription-settings.mutate.ts
@@ -0,0 +1,39 @@
+import { NodeType } from "@marzneshin/features/nodes";
+import { useMutation } from "@tanstack/react-query";
+import { fetch, queryClient } from "@marzneshin/utils";
+import { toast } from "sonner";
+import i18n from "@marzneshin/features/i18n";
+import {
+ subscriptionSettingsQueryKey,
+ SubscriptionSettingsType
+} from "@marzneshin/features/subscription-settings";
+
+export async function updateSubscriptionSettings(settings: SubscriptionSettingsType): Promise<NodeType> {
+ return fetch("/system/settings/subscription", { method: 'put', body: settings }).then((node) => {
+ return node;
+ });
+}
+
+const handleError = () => {
+ toast.error(
+ i18n.t('events.update.error'),
+ )
+}
+
+const handleSuccess = () => {
+ toast.success(
+ i18n.t('events.update.success.title', { name: "Subscription Settings" }),
+ {
+ description: i18n.t('events.update.success.desc')
+ })
+ queryClient.invalidateQueries({ queryKey: [subscriptionSettingsQueryKey] })
+}
+
+export const useSubscriptionSettingsMutation = () => {
+ return useMutation({
+ mutationKey: subscriptionSettingsQueryKey,
+ mutationFn: updateSubscriptionSettings,
+ onError: handleError,
+ onSuccess: handleSuccess,
+ })
+}
diff --git a/dashboard/src/features/subscription-settings/services/subscription-settings.query.ts b/dashboard/src/features/subscription-settings/services/subscription-settings.query.ts
new file mode 100644
index 0000000..e2785c4
--- /dev/null
+++ b/dashboard/src/features/subscription-settings/services/subscription-settings.query.ts
@@ -0,0 +1,23 @@
+import { SubscriptionSettingsType } from "@marzneshin/features/subscription-settings";
+import { useQuery } from "@tanstack/react-query";
+import { fetch } from "@marzneshin/utils";
+
+export async function fetchSubscriptionSettings(): Promise<SubscriptionSettingsType> {
+ return fetch(`/system/settings/subscription`);
+}
+
+export const subscriptionSettingsQueryKey = ["system", "settings", "subscription"]
+
+export const useSubscriptionSettingsQuery = () => {
+ return useQuery({
+ queryKey: subscriptionSettingsQueryKey,
+ queryFn: fetchSubscriptionSettings,
+ initialData: {
+ url_prefix: "",
+ profile_title: "",
+ support_link: "",
+ update_interval: 0,
+ rules: [],
+ },
+ });
+};
diff --git a/dashboard/src/features/subscription-settings/subscription-rule.type.ts b/dashboard/src/features/subscription-settings/subscription-rule.type.ts
new file mode 100644
index 0000000..962cbe6
--- /dev/null
+++ b/dashboard/src/features/subscription-settings/subscription-rule.type.ts
@@ -0,0 +1,15 @@
+export type SubscriptionRuleResultType =
+ "xray" | "v2ray" | "sing-box" | "clash" | "clash-meta" | "block" | "links" | "base64-links" | "template"
+
+export type SubscriptionRuleType = {
+ pattern: string,
+ result: SubscriptionRuleResultType
+}
+
+export type SubscriptionSettingsType = {
+ url_prefix: string;
+ profile_title: string;
+ support_link: string;
+ update_interval: number;
+ rules: SubscriptionRuleType[];
+}
diff --git a/dashboard/src/features/subscription-settings/widget/index.tsx b/dashboard/src/features/subscription-settings/widget/index.tsx
new file mode 100644
index 0000000..1fd46de
--- /dev/null
+++ b/dashboard/src/features/subscription-settings/widget/index.tsx
@@ -0,0 +1,22 @@
+import {
+ SectionWidget
+} from "@marzneshin/components";
+import { SubscriptionRulesForm } from "@marzneshin/features/subscription-settings";
+import { useTranslation } from "react-i18next";
+
+export const SubscriptionSettingsWidget = () => {
+ const { t } = useTranslation()
+ return (
+ <SectionWidget
+ title={
+ <div className="space-y-1">
+ <h4>
+ {t("page.settings.subscription-settings.title")}
+ </h4>
+ </div>
+ }
+ >
+ <SubscriptionRulesForm />
+ </SectionWidget>
+ )
+}
diff --git a/dashboard/src/routes/_dashboard/settings.lazy.tsx b/dashboard/src/routes/_dashboard/settings.lazy.tsx
index 331ffd4..47acf3d 100644
--- a/dashboard/src/routes/_dashboard/settings.lazy.tsx
+++ b/dashboard/src/routes/_dashboard/settings.lazy.tsx
@@ -1,7 +1,8 @@
-import { Card, CardContent, CardHeader, CardTitle, Page } from '@marzneshin/components'
-import { CertificateButton } from '@marzneshin/features/settings'
-import { createFileRoute } from '@tanstack/react-router'
+import { Card, CardContent, CardHeader, CardTitle, Page, VStack } from '@marzneshin/components'
+import { CertificateWidget, ConfigurationWidget } from '@marzneshin/features/settings';
+import { SubscriptionSettingsWidget } from '@marzneshin/features/subscription-settings';
+import { createFileRoute } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next'
export const Settings = () => {
@@ -9,13 +10,17 @@ export const Settings = () => {
return (
<Page>
<Card className="border-0 sm:w-screen md:w-full">
- <CardContent>
- <CardHeader>
- <CardTitle>
- {t('settings')}
- </CardTitle>
- </CardHeader>
- <CertificateButton />
+ <CardHeader>
+ <CardTitle>
+ {t('settings')}
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="sm:flex flex-col lg:grid grid-cols-2 gap-3 h-full">
+ <ConfigurationWidget />
+ <VStack className="gap-3">
+ <SubscriptionSettingsWidget />
+ <CertificateWidget />
+ </VStack>
</CardContent>
</Card>
</Page>
diff --git a/dashboard/src/utils/compose-refs.ts b/dashboard/src/utils/compose-refs.ts
new file mode 100644
index 0000000..f89676a
--- /dev/null
+++ b/dashboard/src/utils/compose-refs.ts
@@ -0,0 +1,36 @@
+// @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/composeRefs.tsx
+
+import * as React from "react"
+
+type PossibleRef<T> = React.Ref<T> | undefined
+
+/**
+ * Set a given ref to a given value
+ * This utility takes care of different types of refs: callback refs and RefObject(s)
+ */
+function setRef<T>(ref: PossibleRef<T>, value: T) {
+ if (typeof ref === "function") {
+ ref(value)
+ } else if (ref !== null && ref !== undefined) {
+ (ref as React.MutableRefObject<T>).current = value
+ }
+}
+
+/**
+ * A utility to compose multiple refs together
+ * Accepts callback refs and RefObject(s)
+ */
+function composeRefs<T>(...refs: PossibleRef<T>[]) {
+ return (node: T) => refs.forEach((ref) => setRef(ref, node))
+}
+
+/**
+ * A custom hook that composes multiple refs
+ * Accepts callback refs and RefObject(s)
+ */
+function useComposedRefs<T>(...refs: PossibleRef<T>[]) {
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ return React.useCallback(composeRefs(...refs), refs)
+}
+
+export { composeRefs, useComposedRefs }
diff --git a/dashboard/src/utils/index.ts b/dashboard/src/utils/index.ts
index d8e6241..fdedd44 100644
--- a/dashboard/src/utils/index.ts
+++ b/dashboard/src/utils/index.ts
@@ -5,3 +5,4 @@ export * from './query-client'
export * from './subscription-link'
export * from './is-online'
export * from './project-info'
+export * from './compose-refs'