├── src
├── hooks
│ └── index.ts
├── models
│ ├── index.ts
│ ├── resolvers
│ │ ├── index.ts
│ │ ├── socks5.ts
│ │ ├── http.ts
│ │ ├── juicity.ts
│ │ ├── vmess.ts
│ │ ├── tuic.ts
│ │ ├── shadowsocksr.ts
│ │ ├── vless.ts
│ │ ├── trojan.ts
│ │ └── shadowsocks.ts
│ └── base.ts
├── apis
│ ├── gql
│ │ ├── index.ts
│ │ └── fragment-masking.ts
│ ├── query.ts
│ └── mutation.ts
├── app
│ ├── favicon.ico
│ ├── assets
│ │ └── twemoji.ttf
│ ├── api
│ │ ├── logout
│ │ │ └── route.ts
│ │ ├── avatar
│ │ │ └── route.ts
│ │ ├── update-password
│ │ │ └── route.ts
│ │ └── login
│ │ │ └── route.ts
│ ├── (protected)
│ │ ├── rule
│ │ │ ├── page.tsx
│ │ │ ├── DNSSection.tsx
│ │ │ └── RoutingSection.tsx
│ │ ├── network
│ │ │ ├── typings.ts
│ │ │ ├── page.tsx
│ │ │ ├── NodeSection.tsx
│ │ │ ├── SubscriptionSection.tsx
│ │ │ └── GroupSection.tsx
│ │ ├── page.tsx
│ │ ├── layout.tsx
│ │ └── providers.tsx
│ ├── error.tsx
│ ├── providers.tsx
│ ├── avatar
│ │ └── [name]
│ │ │ └── route.ts
│ ├── bootstrap.tsx
│ ├── layout.tsx
│ └── (auth)
│ │ └── login
│ │ └── page.tsx
├── instrumentation.ts
├── components
│ ├── Description.tsx
│ ├── LoadingSpinner.tsx
│ ├── Label.tsx
│ ├── Button.tsx
│ ├── LogoText.tsx
│ ├── RandomUnsplashImage.tsx
│ ├── ResourcePage.tsx
│ ├── Editor.tsx
│ ├── CodeBlock.tsx
│ ├── NodeCard.tsx
│ ├── ResourceRadioGroup.tsx
│ ├── ListInput.tsx
│ ├── Modal.tsx
│ └── Header.tsx
├── types
│ └── index.d.ts
├── constants
│ └── index.ts
├── helpers
│ ├── server
│ │ └── data.ts
│ └── index.tsx
├── schemas
│ ├── group.ts
│ ├── subscription.ts
│ ├── routing.ts
│ ├── dns.ts
│ ├── account.ts
│ ├── config.ts
│ └── node.ts
├── bootstrap.ts
├── lib
│ ├── helper.ts
│ ├── time.ts
│ └── time.test.ts
├── editor
│ ├── options.ts
│ ├── index.ts
│ └── languages.ts
├── styles
│ └── globals.css
├── i18n
│ ├── index.ts
│ └── locales
│ │ ├── zh-Hans.json
│ │ └── en-US.json
└── contexts
│ └── index.tsx
├── .dockerignore
├── .prettierignore
├── .eslintignore
├── .npmrc
├── .eslintrc
├── .commitlintrc
├── .gitmodules
├── .husky
├── commit-msg
└── pre-commit
├── docs
├── preview-login.webp
└── commit-msg-guide.md
├── postcss.config.js
├── public
└── 809-1634-rocket-demo.riv
├── .env
├── docker-compose.yml
├── .lintstagedrc
├── .prettierrc
├── vitest.config.ts
├── .github
├── ISSUE_TEMPLATE
│ ├── enhancement.md
│ ├── bug-report.md
│ ├── feature-request.md
│ └── support-request.md
├── dependabot.yml
└── workflows
│ └── release.yml
├── codegen.ts
├── tailwind.config.ts
├── next.config.js
├── .gitignore
├── tsconfig.json
├── Dockerfile
├── LICENSE
├── package.json
├── CONTRIBUTING.md
└── README.md
/src/hooks/index.ts:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | dist
2 | CHANGELOG.md
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | .next
2 | src/apis/gql
3 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | public-hoist-pattern[]=*@nextui-org/*
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/src/models/index.ts:
--------------------------------------------------------------------------------
1 | export * from './base'
2 | export * from './resolvers'
3 |
--------------------------------------------------------------------------------
/src/apis/gql/index.ts:
--------------------------------------------------------------------------------
1 | export * from './fragment-masking'
2 | export * from './gql'
3 |
--------------------------------------------------------------------------------
/.commitlintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "@commitlint/config-conventional"
4 | ]
5 | }
6 |
--------------------------------------------------------------------------------
/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/daeuniverse/daed-revived-next/HEAD/src/app/favicon.ico
--------------------------------------------------------------------------------
/.gitmodules:
--------------------------------------------------------------------------------
1 | [submodule "wing"]
2 | path = wing
3 | url = https://github.com/daeuniverse/dae-wing.git
4 |
--------------------------------------------------------------------------------
/.husky/commit-msg:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | pnpm commitlint -e
5 |
--------------------------------------------------------------------------------
/docs/preview-login.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/daeuniverse/daed-revived-next/HEAD/docs/preview-login.webp
--------------------------------------------------------------------------------
/src/app/assets/twemoji.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/daeuniverse/daed-revived-next/HEAD/src/app/assets/twemoji.ttf
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | pnpm test:unit
5 | pnpm lint-staged
6 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {}
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/public/809-1634-rocket-demo.riv:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/daeuniverse/daed-revived-next/HEAD/public/809-1634-rocket-demo.riv
--------------------------------------------------------------------------------
/.env:
--------------------------------------------------------------------------------
1 | NEXT_PUBLIC_JWT_SECRET=daeuniverse
2 | NEXT_TELEMETRY_DISABLED=1
3 |
4 | HOSTNAME="0.0.0.0"
5 |
6 | WING_API_URL=http://localhost:2023
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 |
3 | services:
4 | daed:
5 | build: .
6 | container_name: daed
7 | ports:
8 | - '3000:3000'
9 |
--------------------------------------------------------------------------------
/.lintstagedrc:
--------------------------------------------------------------------------------
1 | {
2 | "package.json": "sort-package-json",
3 | "*.{js,jsx,ts,tsx}": "eslint --fix",
4 | "*.{js,jsx,ts,tsx,md,html,css,json,yml,yaml}": "prettier --write"
5 | }
6 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 120,
3 | "semi": false,
4 | "singleQuote": true,
5 | "trailingComma": "none",
6 | "plugins": ["prettier-plugin-organize-imports", "prettier-plugin-tailwindcss"]
7 | }
8 |
--------------------------------------------------------------------------------
/src/instrumentation.ts:
--------------------------------------------------------------------------------
1 | export const register = async () => {
2 | if (process.env.NEXT_RUNTIME === 'nodejs') {
3 | const { bootstrap } = await import('~/bootstrap')
4 |
5 | await bootstrap()
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/src/components/Description.tsx:
--------------------------------------------------------------------------------
1 | import { FC } from 'react'
2 |
3 | export const Description: FC<{ children: string }> = ({ children }) => {
4 | return
{children}
5 | }
6 |
--------------------------------------------------------------------------------
/src/app/api/logout/route.ts:
--------------------------------------------------------------------------------
1 | import { cookies } from 'next/headers'
2 | import { NextResponse } from 'next/server'
3 |
4 | export const POST = () => {
5 | cookies().delete('jwtToken')
6 |
7 | return new NextResponse()
8 | }
9 |
--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import react from '@vitejs/plugin-react'
2 | import { defineConfig } from 'vitest/config'
3 |
4 | export default defineConfig({
5 | plugins: [react()],
6 | test: { globals: true, environment: 'jsdom' }
7 | })
8 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/enhancement.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Enhancement Request
3 | about: Suggest an enhancement to the daed project
4 | labels: topic/enhancement
5 | ---
6 |
7 | ## What would you like us to improve
8 |
9 | ## Why is this needed
10 |
--------------------------------------------------------------------------------
/src/components/LoadingSpinner.tsx:
--------------------------------------------------------------------------------
1 | import { FC } from 'react'
2 |
3 | const LoadingSpinner: FC = () =>
4 | LoadingSpinner.displayName = 'LoadingSpinner'
5 |
6 | export { LoadingSpinner }
7 |
--------------------------------------------------------------------------------
/src/components/Label.tsx:
--------------------------------------------------------------------------------
1 | import { FC, ReactNode } from 'react'
2 |
3 | export const Label: FC<{ children: ReactNode }> = ({ children }) => (
4 |
5 | )
6 | Label.displayName = 'Label'
7 |
--------------------------------------------------------------------------------
/src/models/resolvers/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./http"
2 | export * from "./juicity"
3 | export * from "./shadowsocks"
4 | export * from "./shadowsocksr"
5 | export * from "./socks5"
6 | export * from "./trojan"
7 | export * from "./tuic"
8 | export * from "./vless"
9 | export * from "./vmess"
--------------------------------------------------------------------------------
/src/types/index.d.ts:
--------------------------------------------------------------------------------
1 | import { LANG, resources } from '~/i18n'
2 |
3 | declare module 'i18next' {
4 | interface CustomTypeOptions {
5 | returnNull: false
6 | defaultNS: 'translation'
7 | resources: { translation: (typeof resources)[LANG.enUS]['translation'] }
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/src/constants/index.ts:
--------------------------------------------------------------------------------
1 | export const graphqlAPIURL = `http://${process.env.HOSTNAME}:${process.env.PORT}/api/wing/graphql`
2 |
3 | export enum NodeType {
4 | vmess,
5 | vless,
6 | shadowsocks,
7 | shadowsocksR,
8 | trojan,
9 | tuic,
10 | juicity,
11 | http,
12 | socks5
13 | }
14 |
--------------------------------------------------------------------------------
/src/helpers/server/data.ts:
--------------------------------------------------------------------------------
1 | import envPaths from 'env-paths'
2 | import path from 'node:path'
3 |
4 | export const resolveAvatarDataPath = () => path.join(envPaths('daed').data, 'avatars')
5 |
6 | export const resolveAvatarPath = (avatarName: string) => path.join(resolveAvatarDataPath(), avatarName)
7 |
--------------------------------------------------------------------------------
/codegen.ts:
--------------------------------------------------------------------------------
1 | import { CodegenConfig } from '@graphql-codegen/cli'
2 |
3 | export default {
4 | overwrite: true,
5 | schema: process.env.SCHEMA_PATH,
6 | documents: 'src/**/*',
7 | generates: { 'src/apis/gql/': { preset: 'client' } },
8 | hooks: { afterOneFileWrite: ['prettier -w'] }
9 | } satisfies CodegenConfig
10 |
--------------------------------------------------------------------------------
/src/schemas/group.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 | import { Policy } from '~/apis/gql/graphql'
3 |
4 | export const groupFormSchema = z.object({
5 | name: z.string().min(4).max(20),
6 | policy: z.nativeEnum(Policy)
7 | })
8 |
9 | export const groupFormDefault: z.infer = {
10 | name: '',
11 | policy: Policy.MinMovingAvg
12 | }
13 |
--------------------------------------------------------------------------------
/src/bootstrap.ts:
--------------------------------------------------------------------------------
1 | import envPaths from 'env-paths'
2 | import fs from 'node:fs/promises'
3 | import { resolveAvatarDataPath } from '~/helpers/server/data'
4 |
5 | export const bootstrap = async () => {
6 | const { data } = envPaths('daed')
7 |
8 | await fs.mkdir(data, { recursive: true })
9 | await fs.mkdir(resolveAvatarDataPath(), { recursive: true })
10 | }
11 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import { nextui } from '@nextui-org/react'
2 | import { Config } from 'tailwindcss'
3 |
4 | export default {
5 | darkMode: 'class',
6 | content: ['./node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}', './src/**/*.{css,ts,tsx}'],
7 | plugins: [
8 | nextui({
9 | addCommonColors: true
10 | })
11 | ]
12 | } satisfies Config
13 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | module.exports = {
3 | output: process.env.STANDALONE && 'standalone',
4 | experimental: { instrumentationHook: true },
5 | images: { remotePatterns: [{ hostname: 'source.unsplash.com' }] },
6 | rewrites: () => [{ source: '/api/wing/:path*', destination: `${process.env.WING_API_URL}/:path*` }],
7 | reactStrictMode: false
8 | }
9 |
--------------------------------------------------------------------------------
/src/app/(protected)/rule/page.tsx:
--------------------------------------------------------------------------------
1 | import { ConfigSection } from './ConfigSection'
2 | import { DNSSection } from './DNSSection'
3 | import { RoutingSection } from './RoutingSection'
4 |
5 | export default function RulePage() {
6 | return (
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | )
15 | }
16 |
--------------------------------------------------------------------------------
/src/components/Button.tsx:
--------------------------------------------------------------------------------
1 | import { ButtonProps, Button as NextButton } from '@nextui-org/react'
2 | import { ElementRef, forwardRef } from 'react'
3 |
4 | export const Button = forwardRef, ButtonProps>(({ children, ...props }, ref) => (
5 |
6 | {props.isIconOnly && props.isLoading ? null : children}
7 |
8 | ))
9 | Button.displayName = 'Button'
10 |
--------------------------------------------------------------------------------
/src/schemas/subscription.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 |
3 | export const subscriptionFormSchema = z.object({
4 | subscriptions: z
5 | .array(
6 | z.object({
7 | tag: z.string().min(1),
8 | link: z.string().min(1)
9 | })
10 | )
11 | .min(1)
12 | })
13 |
14 | export const subscriptionFormDefault: z.infer = {
15 | subscriptions: [{ tag: '', link: '' }]
16 | }
17 |
--------------------------------------------------------------------------------
/src/components/LogoText.tsx:
--------------------------------------------------------------------------------
1 | import { FC } from 'react'
2 |
3 | const LogoText: FC = () => {
4 | return (
5 |
6 | daed
7 |
8 | )
9 | }
10 | LogoText.displayName = 'LogoText'
11 |
12 | export { LogoText }
13 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 |
3 | updates:
4 | - package-ecosystem: npm
5 | directory: /
6 | schedule:
7 | interval: daily
8 |
9 | - package-ecosystem: docker
10 | directory: /
11 | schedule:
12 | interval: daily
13 |
14 | - package-ecosystem: github-actions
15 | directory: /
16 | schedule:
17 | interval: daily
18 |
19 | - package-ecosystem: gitsubmodule
20 | directory: /
21 | schedule:
22 | interval: daily
23 |
--------------------------------------------------------------------------------
/src/components/RandomUnsplashImage.tsx:
--------------------------------------------------------------------------------
1 | import Image from 'next/image'
2 | import { FC } from 'react'
3 | import { randomUnsplashImageURL } from '~/helpers'
4 |
5 | const width = 512,
6 | height = 288
7 |
8 | export const RandomUnsplashImage: FC<{ sig: string }> = ({ sig }) => (
9 |
16 | )
17 |
--------------------------------------------------------------------------------
/src/schemas/routing.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 |
3 | export const routingFormSchema = z.object({
4 | name: z.string().min(4).max(20),
5 | text: z.string().min(1)
6 | })
7 |
8 | export const routingFormDefault: z.infer = {
9 | name: '',
10 | text: `
11 | pname(NetworkManager, systemd-resolved, dnsmasq) -> must_direct
12 | dip(geoip:private) -> direct
13 | dip(geoip:cn) -> direct
14 | domain(geosite:cn) -> direct
15 | fallback: proxy
16 | `.trim()
17 | }
18 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug-report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug Report
3 | about: Report a bug encountered while operating daed
4 | labels: topic/bug
5 | ---
6 |
7 | ## What happened
8 |
9 | ## What you expect to happen
10 |
11 | ## How to reproduce it (as minimally and precisely as possible)
12 |
13 | ## Anything else we need to know
14 |
15 | ## Environment
16 |
17 | - daed version
18 | use `daed --version`
19 |
20 | - OS
21 | (e.g `cat /etc/os-release`)
22 |
23 | - Kernel
24 | (e.g. `uname -a`)
25 |
--------------------------------------------------------------------------------
/src/components/ResourcePage.tsx:
--------------------------------------------------------------------------------
1 | import { FC, ReactNode } from 'react'
2 |
3 | export const ResourcePage: FC<{ name: string; creation?: ReactNode; children: ReactNode }> = ({
4 | name,
5 | creation,
6 | children
7 | }) => {
8 | return (
9 |
10 |
11 |
{name}
12 |
13 | {creation}
14 |
15 |
16 | {children}
17 |
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/src/schemas/dns.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 |
3 | export const DNSFormSchema = z.object({
4 | name: z.string().min(4).max(20),
5 | text: z.string().min(1)
6 | })
7 |
8 | export const DNSFormDefault: z.infer = {
9 | name: '',
10 | text: `
11 | upstream {
12 | alidns: 'udp://223.5.5.5:53'
13 | googledns: 'tcp+udp://8.8.8.8:53'
14 | }
15 |
16 | routing {
17 | request {
18 | qname(geosite:cn) -> alidns
19 | fallback: googledns
20 | }
21 | }
22 | `.trim()
23 | }
24 |
--------------------------------------------------------------------------------
/src/app/(protected)/network/typings.ts:
--------------------------------------------------------------------------------
1 | export type Node = {
2 | id: string
3 | name: string
4 | tag?: string | null
5 | protocol: string
6 | link: string
7 | subscriptionID?: string | null
8 | }
9 |
10 | export type Subscription = {
11 | id: string
12 | tag?: string | null
13 | updatedAt: string
14 |
15 | nodes: { edges: Node[] }
16 | }
17 |
18 | export type Group = {
19 | id: string
20 | name: string
21 | policy: string
22 | nodes: Node[]
23 | subscriptions: Subscription[]
24 | }
25 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
27 | # local env files
28 | .env*.local
29 |
30 | # vercel
31 | .vercel
32 |
33 | # typescript
34 | *.tsbuildinfo
35 | next-env.d.ts
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature-request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature Request
3 | about: feature request related to daed
4 | labels: topic/feature
5 | ---
6 |
7 |
14 |
15 | ## What feature you would like us to integrate into the daed project
16 |
17 | ## Why is this needed
18 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/support-request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Support Request
3 | about: Support request related to Daed
4 | labels: topic/support
5 | ---
6 |
7 |
16 |
17 | ## What would you like us to support
18 |
--------------------------------------------------------------------------------
/src/app/(protected)/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useState } from 'react'
4 | import { Editor } from '~/components/Editor'
5 |
6 | export default function HomePage() {
7 | const [editorValue, setEditorValue] = useState(
8 | `
9 | # Hello world
10 |
11 | this is a test message
12 | `.trim()
13 | )
14 |
15 | return (
16 |
17 |
daed
18 |
19 | setEditorValue(value || '')} />
20 |
21 | )
22 | }
23 |
--------------------------------------------------------------------------------
/src/components/Editor.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Editor as BaseEditor, EditorProps } from '@monaco-editor/react'
4 | import { useTheme } from 'next-themes'
5 | import { FC } from 'react'
6 | import { options, themeDark, themeLight } from '~/editor/options'
7 |
8 | const Editor: FC = (props) => {
9 | const { resolvedTheme } = useTheme()
10 |
11 | return (
12 |
13 |
14 |
15 | )
16 | }
17 | Editor.displayName = 'Editor'
18 |
19 | export { Editor }
20 |
--------------------------------------------------------------------------------
/src/lib/helper.ts:
--------------------------------------------------------------------------------
1 | export class Defer {
2 | promise: Promise
3 | resolve?: (value: T | PromiseLike) => void
4 | reject?: (reason?: unknown) => void
5 |
6 | constructor() {
7 | this.promise = new Promise((resolve, reject) => {
8 | this.resolve = resolve
9 | this.reject = reject
10 | })
11 | }
12 | }
13 |
14 | export const fileToBase64 = (file: File) => {
15 | const reader = new FileReader()
16 | reader.readAsDataURL(file)
17 |
18 | const defer = new Defer()
19 | reader.onload = () => defer.resolve?.(reader.result as string)
20 | reader.onerror = (err) => defer.reject?.(err)
21 |
22 | return defer.promise
23 | }
24 |
--------------------------------------------------------------------------------
/src/app/api/avatar/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server'
2 | import fs from 'node:fs/promises'
3 | import { resolveAvatarPath } from '~/helpers/server/data'
4 |
5 | export const POST = async (req: Request) => {
6 | const formData = await req.formData()
7 | const avatar = formData.get('avatar') as File
8 |
9 | const avatarPath = resolveAvatarPath(avatar.name)
10 |
11 | try {
12 | await fs.writeFile(avatarPath, Buffer.from(await avatar.arrayBuffer()))
13 |
14 | return NextResponse.json({ url: `/avatar/${avatar.name}` })
15 | } catch (err) {
16 | return NextResponse.json({ message: (err as Error).message }, { status: 503 })
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/app/error.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { IconRefresh } from '@tabler/icons-react'
4 | import { useTranslation } from 'react-i18next'
5 | import { Button } from '~/components/Button'
6 |
7 | export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
8 | const { t } = useTranslation()
9 |
10 | return (
11 |
12 |
{error.message}
13 |
14 |
} onPress={reset}>
15 | {t('actions.refresh')}
16 |
17 |
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/src/editor/options.ts:
--------------------------------------------------------------------------------
1 | import { EditorProps } from '@monaco-editor/react'
2 |
3 | export const themeDark = 'vs-dark'
4 | export const themeLight = 'githubLight'
5 |
6 | export const options: EditorProps['options'] = {
7 | cursorBlinking: 'solid',
8 | folding: false,
9 | fontFamily: 'var(--font-fira-code)',
10 | fontWeight: 'bold',
11 | formatOnPaste: true,
12 | glyphMargin: false,
13 | insertSpaces: true,
14 | lineHeight: 1.6,
15 | lineNumbers: 'off',
16 | minimap: { enabled: false },
17 | padding: { top: 8, bottom: 8 },
18 | renderWhitespace: 'selection',
19 | scrollBeyondLastLine: true,
20 | 'semanticHighlighting.enabled': true,
21 | tabSize: 2
22 | }
23 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "esnext",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "types": ["vitest/globals"],
17 | "plugins": [{ "name": "next" }],
18 | "paths": { "~/*": ["./src/*"] }
19 | },
20 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
21 | "exclude": ["node_modules"]
22 | }
23 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # can't build on bun, because of this issue:
2 | # https://github.com/oven-sh/bun/issues/4671
3 | FROM docker.io/node AS base
4 |
5 | FROM base AS builder
6 |
7 | WORKDIR /build
8 |
9 | COPY . .
10 |
11 | ENV HUSKY 0
12 | ENV STANDALONE 1
13 |
14 | RUN corepack enable
15 | RUN corepack prepare pnpm@latest --activate
16 |
17 | RUN pnpm install
18 | RUN pnpm build
19 |
20 | FROM base AS runner
21 |
22 | WORKDIR /app
23 |
24 | COPY --from=builder /build/public ./public
25 |
26 | RUN mkdir .next
27 |
28 | COPY --from=builder /build/.next/standalone .
29 | COPY --from=builder /build/.next/static ./.next/static
30 |
31 | ENV NODE_ENV production
32 |
33 | EXPOSE 3000
34 |
35 | CMD ["node", "server.js"]
--------------------------------------------------------------------------------
/src/app/providers.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { NextUIProvider } from '@nextui-org/react'
4 | import { ThemeProvider, useTheme } from 'next-themes'
5 | import { FC, ReactNode } from 'react'
6 | import { Toaster } from 'sonner'
7 |
8 | const ToasterProvider = () => {
9 | const { theme } = useTheme()
10 |
11 | return
12 | }
13 |
14 | export const Providers: FC<{ children: ReactNode }> = ({ children }) => (
15 |
16 |
17 | {children}
18 |
19 |
20 |
21 |
22 | )
23 |
--------------------------------------------------------------------------------
/src/components/CodeBlock.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useTheme } from 'next-themes'
4 | import { FC } from 'react'
5 | import SyntaxHighlighter, { SyntaxHighlighterProps } from 'react-syntax-highlighter'
6 | import { atomOneDark, atomOneLight } from 'react-syntax-highlighter/dist/esm/styles/hljs'
7 |
8 | const CodeBlock: FC<{ children: string } & SyntaxHighlighterProps> = ({ children, ...props }) => {
9 | const { resolvedTheme } = useTheme()
10 |
11 | return (
12 |
13 | {children}
14 |
15 | )
16 | }
17 | CodeBlock.displayName = 'CodeBlock'
18 |
19 | export { CodeBlock }
20 |
--------------------------------------------------------------------------------
/src/components/NodeCard.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardBody, CardFooter, CardHeader, Tooltip } from '@nextui-org/react'
2 | import { FC } from 'react'
3 | import { Node } from '~/apis/gql/graphql'
4 | import { RandomUnsplashImage } from '~/components/RandomUnsplashImage'
5 |
6 | export const NodeCard: FC<{ node: Node }> = ({ node }) => (
7 |
8 |
9 | {node.tag || node.name}
10 |
11 |
12 |
13 |
14 |
15 | {node.protocol}
16 |
17 |
18 | )
19 |
--------------------------------------------------------------------------------
/src/app/(protected)/layout.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from 'next/navigation'
2 | import { ReactNode } from 'react'
3 | import { Header } from '~/components/Header'
4 | import { decodeJWTFromCookie } from '~/helpers'
5 | import { Providers } from './providers'
6 |
7 | export default function ProtectedLayout({ children }: { children: ReactNode }) {
8 | const jwtPayload = decodeJWTFromCookie()
9 |
10 | if (!jwtPayload) redirect('/login')
11 |
12 | const { token } = jwtPayload
13 |
14 | return (
15 |
16 |
17 |
18 |
21 |
22 | )
23 | }
24 |
--------------------------------------------------------------------------------
/src/app/(protected)/providers.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
4 | import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
5 | import { FC, ReactNode } from 'react'
6 | import { GraphqlClientProvider, SessionContextProps, SessionProvider } from '~/contexts'
7 |
8 | export const Providers: FC = ({ token, children }) => (
9 |
10 |
11 |
12 | {children}
13 |
14 |
15 |
16 |
17 |
18 | )
19 |
--------------------------------------------------------------------------------
/src/app/avatar/[name]/route.ts:
--------------------------------------------------------------------------------
1 | import mime from 'mime'
2 | import { NextResponse } from 'next/server'
3 | import fs from 'node:fs/promises'
4 | import { resolveAvatarPath } from '~/helpers/server/data'
5 |
6 | export const GET = async (_request: Request, { params }: { params: { name: string } }) => {
7 | const { name } = params
8 | const avatarPath = resolveAvatarPath(name)
9 |
10 | try {
11 | await fs.access(avatarPath, fs.constants.F_OK)
12 |
13 | const fileContent = await fs.readFile(avatarPath)
14 | const fileType = mime.getType(avatarPath) || ''
15 |
16 | return new NextResponse(fileContent, { headers: { 'Content-Type': fileType } })
17 | } catch {
18 | return new NextResponse(null, { status: 404 })
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/styles/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | :root {
6 | scrollbar-width: thin;
7 | scrollbar-color: transparent transparent;
8 | font-family: var(--font-geist-sans), var(--font-twemoji);
9 | }
10 |
11 | ::-webkit-scrollbar {
12 | @apply hidden bg-transparent;
13 | }
14 |
15 | form {
16 | @apply contents;
17 | }
18 |
19 | input {
20 | @apply bg-clip-text;
21 | }
22 |
23 | @media screen(sm) {
24 | ::-webkit-scrollbar {
25 | @apply block;
26 | }
27 |
28 | ::-webkit-scrollbar:vertical {
29 | @apply w-1.5;
30 | }
31 |
32 | ::-webkit-scrollbar:horizontal {
33 | @apply h-1.5;
34 | }
35 |
36 | ::-webkit-scrollbar-thumb {
37 | @apply rounded bg-primary;
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/helpers/index.tsx:
--------------------------------------------------------------------------------
1 | import jwt from 'jsonwebtoken'
2 | import { cookies } from 'next/headers'
3 |
4 | export const randomUnsplashImageURL = (sig: string, width: number, height: number) =>
5 | `https://source.unsplash.com/random/${width}x${height}?goose&sig=${sig}`
6 |
7 | export const decodeJWTFromCookie = () => {
8 | const jwtToken = cookies().get('jwtToken')
9 |
10 | if (!jwtToken) return null
11 |
12 | return jwt.decode(jwtToken.value, { json: true })! as { token: string }
13 | }
14 |
15 | export const storeJWTAsCookie = (token: string) => {
16 | const jwtToken = jwt.sign({ token }, process.env.NEXT_PUBLIC_JWT_SECRET!)
17 |
18 | cookies().set('jwtToken', jwtToken, {
19 | maxAge: 60 * 60 * 24 * 30, // 30 days
20 | httpOnly: true,
21 | path: '/'
22 | })
23 | }
24 |
--------------------------------------------------------------------------------
/src/editor/index.ts:
--------------------------------------------------------------------------------
1 | import { loader } from '@monaco-editor/react'
2 | import type { editor } from 'monaco-editor'
3 | import { daeLang } from '~/editor/languages'
4 |
5 | export const initializeEditor = async () => {
6 | self.MonacoEnvironment = {
7 | getWorker: () => new Worker(new URL('monaco-editor/esm/vs/editor/editor.worker', import.meta.url))
8 | }
9 |
10 | const monaco = await import('monaco-editor')
11 |
12 | loader.config({ monaco })
13 |
14 | const monacoInstance = await loader.init()
15 |
16 | monacoInstance.languages.register({ id: 'dae', extensions: ['dae'] })
17 | monacoInstance.languages.setMonarchTokensProvider('dae', daeLang)
18 |
19 | const themeGithubLight = await import('monaco-themes/themes/GitHub Light.json')
20 |
21 | monacoInstance.editor.defineTheme('githubLight', themeGithubLight as editor.IStandaloneThemeData)
22 | }
23 |
--------------------------------------------------------------------------------
/src/app/bootstrap.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import dayjs from 'dayjs'
4 | import duration from 'dayjs/plugin/duration'
5 | import { FC, ReactNode } from 'react'
6 | import { useAsync } from 'react-use'
7 | import { LoadingSpinner } from '~/components/LoadingSpinner'
8 | import { initializeEditor } from '~/editor'
9 | import { initializeI18n } from '~/i18n'
10 |
11 | export const Bootstrap: FC<{ children: ReactNode }> = ({ children }) => {
12 | const { loading } = useAsync(async () => {
13 | dayjs.extend(duration)
14 |
15 | try {
16 | await initializeI18n()
17 | await initializeEditor()
18 | } catch {}
19 | }, [])
20 |
21 | if (loading) {
22 | return (
23 |
24 |
25 |
26 | Loading assets...
27 |
28 | )
29 | }
30 |
31 | return children
32 | }
33 |
--------------------------------------------------------------------------------
/src/components/ResourceRadioGroup.tsx:
--------------------------------------------------------------------------------
1 | import { Radio, RadioGroup, RadioGroupProps, RadioProps } from '@nextui-org/react'
2 | import { ElementRef, forwardRef } from 'react'
3 |
4 | export const ResourceRadio = forwardRef, RadioProps>((props, ref) => {
5 | return (
6 |
15 | )
16 | })
17 | ResourceRadio.displayName = 'ResourceRadio'
18 |
19 | export const ResourceRadioGroup = forwardRef, RadioGroupProps>((props, ref) => (
20 |
21 | ))
22 | ResourceRadioGroup.displayName = 'ResourceRadioGroup'
23 |
--------------------------------------------------------------------------------
/src/i18n/index.ts:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import i18n from 'i18next'
4 | import detectLanguage from 'i18next-browser-languagedetector'
5 | import { initReactI18next } from 'react-i18next'
6 | import { z } from 'zod'
7 | import { zodI18nMap } from 'zod-i18n-map'
8 | import zodEn from 'zod-i18n-map/locales/en/zod.json'
9 | import zodZhHans from 'zod-i18n-map/locales/zh-CN/zod.json'
10 | import enUS from '~/i18n/locales/en-US.json'
11 | import zhHans from '~/i18n/locales/zh-Hans.json'
12 |
13 | export enum LANG {
14 | enUS = 'en-US',
15 | zhHans = 'zh-Hans'
16 | }
17 |
18 | export const resources = {
19 | [LANG.enUS]: { translation: enUS, zod: zodEn },
20 | [LANG.zhHans]: { translation: zhHans, zod: zodZhHans }
21 | } as const
22 |
23 | export const initializeI18n = async () => {
24 | await i18n.use(initReactI18next).use(detectLanguage).init({
25 | resources,
26 | fallbackLng: LANG.enUS,
27 | returnNull: false
28 | })
29 |
30 | z.setErrorMap(zodI18nMap)
31 | }
32 |
--------------------------------------------------------------------------------
/src/app/api/update-password/route.ts:
--------------------------------------------------------------------------------
1 | import { request } from 'graphql-request'
2 | import { NextResponse } from 'next/server'
3 | import { UpdatePasswordMutation } from '~/apis/gql/graphql'
4 | import { updatePasswordMutation } from '~/apis/mutation'
5 | import { graphqlAPIURL } from '~/constants'
6 | import { decodeJWTFromCookie, storeJWTAsCookie } from '~/helpers'
7 |
8 | export const POST = async (req: Request) => {
9 | const jwtPayload = decodeJWTFromCookie()
10 |
11 | if (!jwtPayload) {
12 | return new NextResponse(null, { status: 401 })
13 | }
14 |
15 | const { currentPassword, newPassword } = await req.json()
16 |
17 | const { token } = jwtPayload
18 |
19 | const { updatePassword } = await request(
20 | graphqlAPIURL,
21 | updatePasswordMutation,
22 | { currentPassword, newPassword },
23 | { Authorization: `Bearer ${token}` }
24 | )
25 |
26 | storeJWTAsCookie(updatePassword)
27 |
28 | return new NextResponse()
29 | }
30 |
--------------------------------------------------------------------------------
/src/lib/time.ts:
--------------------------------------------------------------------------------
1 | import dayjs from 'dayjs'
2 |
3 | const timeReg = /([0-9]+)([a-zA-Z]+)/
4 |
5 | type Time = {
6 | hours: number
7 | minutes: number
8 | seconds: number
9 | milliseconds: number
10 | }
11 |
12 | export const parseDigitAndUnit = (
13 | timeStr: string,
14 | output: Time = { hours: 0, milliseconds: 0, minutes: 0, seconds: 0 }
15 | ): Time => {
16 | const matchRes = timeStr.match(timeReg)
17 |
18 | if (!matchRes) return output
19 |
20 | const digit = Number.parseInt(matchRes[1])
21 | const unit = matchRes[2].toLowerCase()
22 |
23 | switch (unit) {
24 | case 'h':
25 | output.hours = digit
26 |
27 | break
28 | case 'm':
29 | output.minutes = digit
30 |
31 | break
32 | case 's':
33 | output.seconds = digit
34 |
35 | break
36 | case 'ms':
37 | output.milliseconds = digit
38 |
39 | break
40 | }
41 |
42 | return parseDigitAndUnit(timeStr.replace(timeReg, ''), output)
43 | }
44 |
45 | export const deriveTime = (timeStr: string, outputUnit: 'ms' | 's') =>
46 | dayjs.duration(parseDigitAndUnit(timeStr)).as(outputUnit === 'ms' ? 'milliseconds' : 'seconds')
47 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License Copyright (c) 2023 daeuniverse
2 |
3 | Permission is hereby granted, free of
4 | charge, to any person obtaining a copy of this software and associated
5 | documentation files (the "Software"), to deal in the Software without
6 | restriction, including without limitation the rights to use, copy, modify, merge,
7 | publish, distribute, sublicense, and/or sell copies of the Software, and to
8 | permit persons to whom the Software is furnished to do so, subject to the
9 | following conditions:
10 |
11 | The above copyright notice and this permission notice
12 | (including the next paragraph) shall be included in all copies or substantial
13 | portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
18 | EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
19 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
--------------------------------------------------------------------------------
/src/schemas/account.ts:
--------------------------------------------------------------------------------
1 | import { useCallback } from 'react'
2 | import { useTranslation } from 'react-i18next'
3 | import { z } from 'zod'
4 |
5 | export const loginSchema = z.object({
6 | username: z.string().min(4).max(20),
7 | password: z.string().min(6).max(20)
8 | })
9 |
10 | export const loginFormDefault: z.infer = {
11 | username: '',
12 | password: ''
13 | }
14 |
15 | export const updatePasswordSchema = z.object({
16 | currentPassword: z.string().min(6).max(20),
17 | newPassword: z.string().min(6).max(20),
18 | confirmPassword: z.string().min(6).max(20)
19 | })
20 |
21 | export const useUpdatePasswordSchemaWithRefine = () => {
22 | const { t } = useTranslation()
23 |
24 | return useCallback(
25 | () =>
26 | updatePasswordSchema.refine(({ newPassword, confirmPassword }) => newPassword === confirmPassword, {
27 | message: t('form.errors.passwordDontMatch'),
28 | path: ['confirmPassword']
29 | }),
30 |
31 | [t]
32 | )
33 | }
34 |
35 | export const updatePasswordFormDefault: z.infer = {
36 | currentPassword: '',
37 | newPassword: '',
38 | confirmPassword: ''
39 | }
40 |
--------------------------------------------------------------------------------
/src/lib/time.test.ts:
--------------------------------------------------------------------------------
1 | import dayjs from 'dayjs'
2 | import duration from 'dayjs/plugin/duration'
3 |
4 | import { deriveTime } from './time'
5 |
6 | beforeAll(() => {
7 | dayjs.extend(duration)
8 | })
9 |
10 | test('deriveTime can parse hours', () => {
11 | expect(deriveTime('1h', 's')).toBe(3600)
12 | expect(deriveTime('1H', 's')).toBe(3600)
13 | })
14 |
15 | test('deriveTime can parse minutes', () => {
16 | expect(deriveTime('1m', 's')).toBe(60)
17 | expect(deriveTime('1M', 's')).toBe(60)
18 | })
19 |
20 | test('deriveTime can parse seconds', () => {
21 | expect(deriveTime('60s', 's')).toBe(60)
22 | expect(deriveTime('60S', 's')).toBe(60)
23 | })
24 |
25 | test('deriveTime can parse multiple units combined', () => {
26 | expect(deriveTime('1h1m1s100ms', 's')).toBe(3661.1)
27 | expect(deriveTime('1H1m1S100mS', 's')).toBe(3661.1)
28 | })
29 |
30 | test('deriveTime can parse seconds to milliseconds', () => {
31 | expect(deriveTime('1s', 'ms')).toBe(1000)
32 | expect(deriveTime('1S', 'ms')).toBe(1000)
33 | })
34 |
35 | test('deriveTime can parse milliseconds to seconds', () => {
36 | expect(deriveTime('1000ms', 's')).toBe(1)
37 | expect(deriveTime('1000Ms', 's')).toBe(1)
38 | })
39 |
--------------------------------------------------------------------------------
/src/components/ListInput.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Input } from '@nextui-org/react'
4 | import { IconPlus, IconTrash } from '@tabler/icons-react'
5 | import { FC } from 'react'
6 | import { Controller, useFieldArray } from 'react-hook-form'
7 | import { Button } from '~/components/Button'
8 |
9 | export const ListInput: FC<{ name: string }> = ({ name }) => {
10 | const { fields, append, remove } = useFieldArray({ name })
11 |
12 | return (
13 |
14 | {fields.map((item, index) => (
15 |
(
19 |
20 |
21 |
22 |
25 |
26 | )}
27 | />
28 | ))}
29 |
30 |
31 |
34 |
35 |
36 | )
37 | }
38 | ListInput.displayName = 'ListInput'
39 |
--------------------------------------------------------------------------------
/src/models/resolvers/socks5.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 | import { NodeType } from '~/constants'
3 | import { BaseNodeResolver } from '~/models'
4 | import { GenerateURLParams } from '~/models/base'
5 | import { socks5Default, socks5Schema } from '~/schemas/node'
6 |
7 | export class Socks5NodeResolver extends BaseNodeResolver {
8 | type = NodeType.socks5
9 | schema = socks5Schema
10 | defaultValues = socks5Default
11 |
12 | generate = (values: z.infer) => {
13 | const generateURLParams: GenerateURLParams = {
14 | protocol: 'socks5',
15 | host: values.host,
16 | port: values.port,
17 | hash: values.name
18 | }
19 |
20 | if (values.username && values.password) {
21 | Object.assign(generateURLParams, {
22 | username: values.username,
23 | password: values.password
24 | })
25 | }
26 |
27 | return this.generateURL(generateURLParams)
28 | }
29 |
30 | resolve(url: string) {
31 | const u = this.parseURL(url)
32 |
33 | return {
34 | username: decodeURIComponent(u.username),
35 | password: decodeURIComponent(u.password),
36 | host: u.host,
37 | port: u.port,
38 | protocol: u.protocol,
39 | name: decodeURIComponent(u.hash)
40 | }
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/app/(protected)/network/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useTranslation } from 'react-i18next'
4 | import { useNodesQuery, useSubscriptionsQuery } from '~/apis/query'
5 | import { GroupSection } from '~/app/(protected)/network/GroupSection'
6 | import { NodeSection } from '~/app/(protected)/network/NodeSection'
7 | import { SubscriptionSection } from '~/app/(protected)/network/SubscriptionSection'
8 | import { ResourcePage } from '~/components/ResourcePage'
9 |
10 | export default function NetworkPage() {
11 | const { t } = useTranslation()
12 |
13 | const subscriptionsQuery = useSubscriptionsQuery()
14 | const nodesQuery = useNodesQuery()
15 |
16 | return (
17 |
18 |
19 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | )
32 | }
33 |
--------------------------------------------------------------------------------
/src/models/resolvers/http.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 | import { NodeType } from '~/constants'
3 | import { BaseNodeResolver } from '~/models'
4 | import { GenerateURLParams } from '~/models/base'
5 | import { httpDefault, httpSchema } from '~/schemas/node'
6 |
7 | export class HTTPNodeResolver extends BaseNodeResolver {
8 | type = NodeType.http
9 | schema = httpSchema
10 | defaultValues = httpDefault
11 |
12 | generate = (values: z.infer & { protocol: 'http' | 'https' }) => {
13 | const generateURLParams: GenerateURLParams = {
14 | protocol: values.protocol,
15 | host: values.host,
16 | port: values.port,
17 | hash: values.name
18 | }
19 |
20 | if (values.username && values.password) {
21 | Object.assign(generateURLParams, {
22 | username: values.username,
23 | password: values.password
24 | })
25 | }
26 |
27 | return this.generateURL(generateURLParams)
28 | }
29 |
30 | resolve(url: string) {
31 | const u = this.parseURL(url)
32 |
33 | return {
34 | username: decodeURIComponent(u.username),
35 | password: decodeURIComponent(u.password),
36 | host: u.host,
37 | port: u.port,
38 | protocol: u.protocol,
39 | name: decodeURIComponent(u.hash)
40 | }
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/models/resolvers/juicity.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 | import { NodeType } from '~/constants'
3 | import { BaseNodeResolver } from '~/models'
4 | import { juicityDefault, juicitySchema } from '~/schemas/node'
5 |
6 | export class JuicityNodeResolver extends BaseNodeResolver {
7 | type = NodeType.juicity
8 | schema = juicitySchema
9 | defaultValues = juicityDefault
10 |
11 | generate = (values: z.infer) =>
12 | this.generateURL({
13 | protocol: 'juicity',
14 | username: values.uuid,
15 | password: values.password,
16 | host: values.server,
17 | port: values.port,
18 | hash: values.name,
19 | params: {
20 | congestion_control: values.congestion_control,
21 | sni: values.sni,
22 | allow_insecure: values.allowInsecure
23 | }
24 | })
25 |
26 | resolve(url: string) {
27 | const u = this.parseURL(url)
28 |
29 | return {
30 | name: decodeURIComponent(u.hash),
31 | uuid: decodeURIComponent(u.username),
32 | password: decodeURIComponent(u.password),
33 | server: u.host,
34 | port: u.port,
35 | sni: u.params.sni || '',
36 | allowInsecure: u.params.allow_insecure === true || u.params.allow_insecure === '1',
37 | pinnedCertchainSha256: u.params.pinned_certchain_sha256 || '',
38 | congestion_control: u.params.congestion_control || 'bbr'
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/models/resolvers/vmess.ts:
--------------------------------------------------------------------------------
1 | import { Base64 } from 'js-base64'
2 | import { z } from 'zod'
3 | import { NodeType } from '~/constants'
4 | import { BaseNodeResolver } from '~/models'
5 | import { v2rayDefault, v2raySchema } from '~/schemas/node'
6 |
7 | export class VmessNodeResolver extends BaseNodeResolver {
8 | type = NodeType.vmess
9 | schema = v2raySchema
10 | defaultValues = v2rayDefault
11 |
12 | generate(values: z.infer) {
13 | const { net } = values
14 | const body: Record = structuredClone(values)
15 |
16 | switch (net) {
17 | case 'kcp':
18 | case 'tcp':
19 | default:
20 | body.type = ''
21 | }
22 |
23 | switch (body.net) {
24 | case 'ws':
25 | case 'h2':
26 | case 'grpc':
27 | case 'kcp':
28 | default:
29 | if (body.net === 'tcp' && body.type === 'http') {
30 | break
31 | }
32 |
33 | body.path = ''
34 | }
35 |
36 | if (!(body.protocol === 'vless' && body.tls === 'xtls')) {
37 | delete body.flow
38 | }
39 |
40 | return 'vmess://' + Base64.encode(JSON.stringify(body))
41 | }
42 |
43 | resolve(url: string) {
44 | const values = JSON.parse(Base64.decode(url.substring(url.indexOf('://') + 3)))
45 |
46 | values.ps = decodeURIComponent(values.ps)
47 | values.tls = values.tls || 'none'
48 | values.type = values.type || 'none'
49 | values.scy = values.scy || 'auto'
50 |
51 | return values
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/models/resolvers/tuic.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 | import { NodeType } from '~/constants'
3 | import { BaseNodeResolver } from '~/models'
4 | import { tuicDefault, tuicSchema } from '~/schemas/node'
5 |
6 | export class TuicNodeResolver extends BaseNodeResolver {
7 | type = NodeType.tuic
8 | schema = tuicSchema
9 | defaultValues = tuicDefault
10 |
11 | generate = (values: z.infer) =>
12 | this.generateURL({
13 | protocol: 'tuic',
14 | username: values.uuid,
15 | password: values.password,
16 | host: values.server,
17 | port: values.port,
18 | hash: values.name,
19 | params: {
20 | congestion_control: values.congestion_control,
21 | alpn: values.alpn,
22 | sni: values.sni,
23 | allow_insecure: values.allowInsecure,
24 | disable_sni: values.disable_sni,
25 | udp_relay_mode: values.udp_relay_mode
26 | }
27 | })
28 |
29 | resolve(url: string) {
30 | const u = this.parseURL(url)
31 |
32 | return {
33 | name: decodeURIComponent(u.hash),
34 | uuid: decodeURIComponent(u.username),
35 | password: decodeURIComponent(u.password),
36 | server: u.host,
37 | port: u.port,
38 | sni: u.params.sni || '',
39 | allowInsecure: u.params.allow_insecure === true || u.params.allow_insecure === '1',
40 | disable_sni: u.params.disable_sni === true || u.params.disable_sni === '1',
41 | alpn: u.params.alpn,
42 | congestion_control: u.params.congestion_control || 'bbr',
43 | udp_relay_mode: u.params.udp_relay_mode || 'native'
44 | }
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: release
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | jobs:
9 | release-please:
10 | runs-on: ubuntu-latest
11 | outputs:
12 | release_created: ${{ steps.release.outputs.release_created }}
13 | tag_name: ${{ steps.release.outputs.tag_name }}
14 |
15 | steps:
16 | - name: release-please
17 | id: release
18 | uses: google-github-actions/release-please-action@v3
19 | with:
20 | token: ${{ secrets.GITHUB_TOKEN }}
21 | release-type: node
22 |
23 | release-image:
24 | needs: release-please
25 | runs-on: ubuntu-latest
26 | if: ${{ needs.release-please.outputs.release_created }}
27 | steps:
28 | - uses: actions/checkout@v4
29 |
30 | - name: set up QEMU
31 | uses: docker/setup-qemu-action@v3
32 |
33 | - name: set up docker buildx
34 | uses: docker/setup-buildx-action@v3
35 | id: buildx
36 |
37 | - name: login to ghcr.io
38 | uses: docker/login-action@v3
39 | with:
40 | registry: ghcr.io
41 | username: ${{ github.actor }}
42 | password: ${{ secrets.GITHUB_TOKEN }}
43 |
44 | - name: build and publish ghcr.io docker image
45 | uses: docker/build-push-action@v5
46 | with:
47 | context: .
48 | builder: ${{ steps.buildx.outputs.name }}
49 | file: Dockerfile
50 | platforms: linux/amd64,linux/arm64
51 | push: true
52 | tags: |
53 | ghcr.io/${{ github.repository }}:${{ needs.release-please.outputs.tag_name }}
54 | cache-from: type=gha
55 | cache-to: type=gha,mode=max
56 |
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from '@nextui-org/react'
2 | import { GeistSans } from 'geist/font'
3 | import { Metadata, Viewport } from 'next'
4 | import { Fira_Code } from 'next/font/google'
5 | import localFont from 'next/font/local'
6 | import { ReactNode } from 'react'
7 | import { Bootstrap } from './bootstrap'
8 | import { Providers } from './providers'
9 |
10 | import '~/styles/globals.css'
11 |
12 | // for code editor
13 | const firaCode = Fira_Code({
14 | subsets: ['latin', 'latin-ext', 'cyrillic', 'cyrillic-ext'],
15 | weight: ['400', '700'],
16 | variable: '--font-fira-code'
17 | })
18 |
19 | // for emojis and other characters, such as national flags
20 | const twemojiFont = localFont({
21 | src: './assets/twemoji.ttf',
22 | variable: '--font-twemoji',
23 | display: 'swap'
24 | })
25 |
26 | export const metadata: Metadata = {
27 | title: 'daed',
28 | description: 'A modern dashboard for dae'
29 | }
30 |
31 | export const viewport: Viewport = {
32 | colorScheme: 'dark light',
33 | width: 'device-width',
34 | initialScale: 1,
35 | minimumScale: 1,
36 | maximumScale: 1,
37 | userScalable: false,
38 | viewportFit: 'cover'
39 | }
40 |
41 | export default async function RootLayout({ children }: { children: ReactNode }) {
42 | return (
43 |
48 |
49 |
50 |
51 |
52 | {children}
53 |
54 |
55 |
56 |
57 |
58 | )
59 | }
60 |
--------------------------------------------------------------------------------
/src/editor/languages.ts:
--------------------------------------------------------------------------------
1 | import { languages } from 'monaco-editor'
2 |
3 | export const daeLang: languages.IMonarchLanguage = {
4 | // set defaultToken as `invalid` to turn on debug mode
5 | // defaultToken: 'invalid',
6 | ignoreCase: false,
7 | keywords: [
8 | 'dip',
9 | 'direct',
10 | 'domain',
11 | 'dport',
12 | 'fallback',
13 | 'ipversion',
14 | 'l4proto',
15 | 'mac',
16 | 'must_direct',
17 | 'pname',
18 | 'qname',
19 | 'request',
20 | 'response',
21 | 'routing',
22 | 'sip',
23 | 'sport',
24 | 'tcp',
25 | 'udp',
26 | 'upstream'
27 | ],
28 |
29 | escapes: /\\(?:[abfnrtv\\"']|x[0-9A-Fa-f]{1,4}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8})/,
30 |
31 | symbols: /[->&!:,]+/,
32 |
33 | operators: ['&&', '!'],
34 |
35 | tokenizer: {
36 | root: [
37 | [/@[a-zA-Z]\w*/, 'tag'],
38 |
39 | [/[a-zA-Z]\w*/, { cases: { '@keywords': 'keyword', '@default': 'identifier' } }],
40 |
41 | { include: '@whitespace' },
42 |
43 | [/[{}()]/, '@brackets'],
44 |
45 | [/@symbols/, { cases: { '@operators': 'operator', '@default': '' } }],
46 |
47 | [/\d+/, 'number'],
48 |
49 | [/[,:]/, 'delimiter'],
50 |
51 | [/"([^"\\]|\\.)*$/, 'string.invalid'],
52 | [/'([^'\\]|\\.)*$/, 'string.invalid'],
53 | [/"/, 'string', '@string_double'],
54 | [/'/, 'string', '@string_single']
55 | ],
56 |
57 | string_double: [
58 | [/[^\\"]+/, 'string'],
59 | [/@escapes/, 'string.escape'],
60 | [/\\./, 'string.escape.invalid'],
61 | [/"/, 'string', '@pop']
62 | ],
63 |
64 | string_single: [
65 | [/[^\\']+/, 'string'],
66 | [/@escapes/, 'string.escape'],
67 | [/\\./, 'string.escape.invalid'],
68 | [/'/, 'string', '@pop']
69 | ],
70 |
71 | whitespace: [
72 | [/[ \t\r\n]+/, 'white'],
73 | [/#.*$/, 'comment']
74 | ]
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/src/contexts/index.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { ClientError, GraphQLClient } from 'graphql-request'
4 | import { useRouter } from 'next/navigation'
5 | import { FC, ReactNode, createContext, useContext, useMemo } from 'react'
6 | import { toast } from 'sonner'
7 |
8 | export type SessionContextProps = { token: string }
9 |
10 | const SessionContext = createContext(null as unknown as { token: string })
11 |
12 | export const useSession = () => useContext(SessionContext)
13 |
14 | export const SessionProvider: FC = ({ token, children }) => {
15 | const router = useRouter()
16 |
17 | if (!token) {
18 | router.replace('/login')
19 |
20 | return null
21 | }
22 |
23 | return {children}
24 | }
25 |
26 | const GraphqlClientContext = createContext(null as unknown as GraphQLClient)
27 |
28 | export const useGraphqlClient = () => useContext(GraphqlClientContext)
29 |
30 | export const GraphqlClientProvider: FC<{ children: ReactNode }> = ({ children }) => {
31 | const router = useRouter()
32 | const { token } = useSession()
33 |
34 | const graphqlClient = useMemo(
35 | () =>
36 | new GraphQLClient('/api/wing/graphql', {
37 | headers: { Authorization: `Bearer ${token}` },
38 | responseMiddleware: (response) => {
39 | const error = (response as ClientError).response?.errors?.[0]
40 |
41 | if (!error) {
42 | return response
43 | }
44 |
45 | if (error.message === 'access denied') {
46 | router.replace('/login')
47 | } else {
48 | toast.error(error?.message)
49 | }
50 | }
51 | }),
52 | [token, router]
53 | )
54 |
55 | return {children}
56 | }
57 |
--------------------------------------------------------------------------------
/src/models/resolvers/shadowsocksr.ts:
--------------------------------------------------------------------------------
1 | import { Base64 } from 'js-base64'
2 | import { z } from 'zod'
3 | import { NodeType } from '~/constants'
4 | import { BaseNodeResolver } from '~/models'
5 | import { ssrDefault, ssrSchema } from '~/schemas/node'
6 |
7 | export class ShadowsocksRNodeResolver extends BaseNodeResolver {
8 | type = NodeType.shadowsocksR
9 | schema = ssrSchema
10 | defaultValues = ssrDefault
11 |
12 | generate = (values: z.infer) =>
13 | /* ssr://server:port:proto:method:obfs:URLBASE64(password)/?remarks=URLBASE64(remarks)&protoparam=URLBASE64(protoparam)&obfsparam=URLBASE64(obfsparam)) */
14 | `ssr://${Base64.encode(
15 | `${values.server}:${values.port}:${values.proto}:${values.method}:${values.obfs}:${Base64.encodeURI(
16 | values.password
17 | )}/?remarks=${Base64.encodeURI(values.name)}&protoparam=${Base64.encodeURI(
18 | values.protoParam
19 | )}&obfsparam=${Base64.encodeURI(values.obfsParam)}`
20 | )}`
21 |
22 | resolve(url: string) {
23 | url = Base64.decode(url.substring(6))
24 |
25 | const arr = url.split('/?')
26 | const query = arr[1].split('&')
27 |
28 | const m: Record = {}
29 |
30 | for (const param of query) {
31 | const pair = param.split('=', 2)
32 | const key = pair[0]
33 | const val = Base64.decode(pair[1])
34 |
35 | m[key] = val
36 | }
37 |
38 | let pre = arr[0].split(':')
39 |
40 | if (pre.length > 6) {
41 | //如果长度多于6,说明host中包含字符:,重新合并前几个分组到host去
42 | pre[pre.length - 6] = pre.slice(0, pre.length - 5).join(':')
43 | pre = pre.slice(pre.length - 6)
44 | }
45 |
46 | pre[5] = Base64.decode(pre[5])
47 |
48 | return {
49 | method: pre[3],
50 | password: pre[5],
51 | server: pre[0],
52 | port: pre[1],
53 | name: m['remarks'],
54 | proto: pre[2],
55 | protoParam: m['protoparam'],
56 | obfs: pre[4],
57 | obfsParam: m['obfsparam']
58 | } as unknown as z.infer
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/models/resolvers/vless.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 | import { NodeType } from '~/constants'
3 | import { BaseNodeResolver } from '~/models'
4 | import { v2rayDefault, v2raySchema } from '~/schemas/node'
5 |
6 | export class VlessNodeResolver extends BaseNodeResolver {
7 | type = NodeType.vless
8 | schema = v2raySchema
9 | defaultValues = v2rayDefault
10 |
11 | generate(values: z.infer) {
12 | const { net, tls, path, host, type, sni, flow, allowInsecure, alpn, id, add, port, ps } = values
13 |
14 | const params: Record = {
15 | type: net,
16 | security: tls,
17 | path,
18 | host,
19 | headerType: type,
20 | sni,
21 | flow,
22 | allowInsecure
23 | }
24 |
25 | if (alpn !== '') params.alpn = alpn
26 |
27 | if (net === 'grpc') params.serviceName = path
28 |
29 | if (net === 'kcp') params.seed = path
30 |
31 | return this.generateURL({
32 | protocol: 'vless',
33 | username: id,
34 | host: add,
35 | port,
36 | hash: ps,
37 | params
38 | })
39 | }
40 |
41 | resolve(url: string) {
42 | const u = this.parseURL(url)
43 |
44 | const o: z.infer = {
45 | ps: decodeURIComponent(u.hash),
46 | add: u.host,
47 | port: u.port,
48 | id: decodeURIComponent(u.username),
49 | net: u.params.type || 'tcp',
50 | type: u.params.headerType || 'none',
51 | host: u.params.host || u.params.sni || '',
52 | path: u.params.path || u.params.serviceName || '',
53 | alpn: u.params.alpn || '',
54 | flow: u.params.flow || 'none',
55 | sni: u.params.sni || '',
56 | tls: u.params.security || 'none',
57 | allowInsecure: u.params.allowInsecure || false,
58 | aid: 0,
59 | scy: 'none',
60 | v: ''
61 | }
62 |
63 | if (o.alpn !== '') {
64 | o.alpn = decodeURIComponent(o.alpn)
65 | }
66 |
67 | if (o.net === 'kcp') {
68 | o.path = u.params.seed
69 | }
70 |
71 | return o
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/src/components/Modal.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { ModalFooter, ModalFooterProps, ModalProps, Modal as NextModal, cn } from '@nextui-org/react'
4 | import { ElementRef, forwardRef } from 'react'
5 | import { useTranslation } from 'react-i18next'
6 | import { Button } from '~/components/Button'
7 |
8 | export const Modal = forwardRef, ModalProps>(({ className, ...props }, ref) => (
9 |
10 | ))
11 | Modal.displayName = 'Modal'
12 |
13 | export const ModalSubmitFormFooter = forwardRef<
14 | ElementRef,
15 | ModalFooterProps & {
16 | isResetDisabled?: boolean
17 | isSubmitDisabled?: boolean
18 | reset: () => void
19 | isSubmitting?: boolean
20 | }
21 | >(({ isResetDisabled, isSubmitDisabled, reset, isSubmitting, ...props }, ref) => {
22 | const { t } = useTranslation()
23 |
24 | return (
25 |
26 |
29 |
30 |
33 |
34 | )
35 | })
36 | ModalSubmitFormFooter.displayName = 'ModalSubmitFormFooter'
37 |
38 | export const ModalConfirmFormFooter = forwardRef<
39 | ElementRef,
40 | ModalFooterProps & {
41 | isSubmitting?: boolean
42 | onCancel: () => void
43 | onConfirm?: () => Promise
44 | }
45 | >(({ onCancel, onConfirm, isSubmitting, ...props }, ref) => {
46 | const { t } = useTranslation()
47 |
48 | return (
49 |
50 |
53 |
54 |
57 |
58 | )
59 | })
60 | ModalConfirmFormFooter.displayName = 'ModalConfirmFormFooter'
61 |
--------------------------------------------------------------------------------
/src/models/resolvers/trojan.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 | import { NodeType } from '~/constants'
3 | import { BaseNodeResolver } from '~/models'
4 | import { trojanDefault, trojanSchema } from '~/schemas/node'
5 |
6 | export class TrojanNodeResolver extends BaseNodeResolver {
7 | type = NodeType.trojan
8 | schema = trojanSchema
9 | defaultValues = trojanDefault
10 |
11 | generate = (values: z.infer) => {
12 | const query: Record = {
13 | allowInsecure: values.allowInsecure
14 | }
15 |
16 | if (values.peer !== '') {
17 | query.sni = values.peer
18 | }
19 |
20 | let protocol = 'trojan'
21 |
22 | if (values.method !== 'origin' || values.obfs !== 'none') {
23 | protocol = 'trojan-go'
24 | query.type = values.obfs === 'none' ? 'original' : 'ws'
25 |
26 | if (values.method === 'shadowsocks') {
27 | query.encryption = `ss;${values.ssCipher};${values.ssPassword}`
28 | }
29 |
30 | if (query.type === 'ws') {
31 | query.host = values.host || ''
32 | query.path = values.path || '/'
33 | }
34 |
35 | delete query.allowInsecure
36 | }
37 |
38 | return this.generateURL({
39 | protocol,
40 | username: values.password,
41 | host: values.server,
42 | port: values.port,
43 | hash: values.name,
44 | params: query
45 | })
46 | }
47 |
48 | resolve(url: string) {
49 | const u = this.parseURL(url)
50 |
51 | const o: Record = {
52 | password: decodeURIComponent(u.username),
53 | server: u.host,
54 | port: u.port,
55 | name: decodeURIComponent(u.hash),
56 | peer: u.params.peer || u.params.sni || '',
57 | allowInsecure: u.params.allowInsecure === true || u.params.allowInsecure === '1',
58 | method: 'origin',
59 | obfs: 'none',
60 | ssCipher: 'aes-128-gcm'
61 | }
62 |
63 | if (url.toLowerCase().startsWith('' + '')) {
64 | if (u.params.encryption?.startsWith('ss;')) {
65 | o.method = 'shadowsocks'
66 | const fields = u.params.encryption.split(';')
67 | o.ssCipher = fields[1]
68 | o.ssPassword = fields[2]
69 | }
70 |
71 | const obfsMap = {
72 | original: 'none',
73 | '': 'none',
74 | ws: 'websocket'
75 | }
76 |
77 | o.obfs = obfsMap[(u.params.type as keyof typeof obfsMap) || '']
78 |
79 | if (o.obfs === 'ws') {
80 | o.obfs = 'websocket'
81 | }
82 |
83 | if (o.obfs === 'websocket') {
84 | o.host = u.params.host || ''
85 | o.path = u.params.path || '/'
86 | }
87 | }
88 |
89 | return o as unknown as z.infer
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/docs/commit-msg-guide.md:
--------------------------------------------------------------------------------
1 | # Semantic Commit Messages
2 |
3 | ## The reasons for these conventions
4 |
5 | - automatic generating of the changelog
6 | - simple navigation through Git history (e.g. ignoring the style changes)
7 |
8 | See how a minor change to your commit message style can make you a better developer.
9 |
10 | ## Format
11 |
12 | ```
13 | `(): `
14 |
15 | `` is optional
16 | ```
17 |
18 | ## Example
19 |
20 | ```
21 | feat: add hat wobble
22 | ^--^ ^------------^
23 | | |
24 | | +-> Summary in present tense.
25 | |
26 | +-------> Type: chore, docs, feat, fix, refactor, style, or test.
27 | ```
28 |
29 | Example `` values:
30 |
31 | - `feat`: (new feature for the user, not a new feature for build script)
32 | - `fix`: (bug fix for the user, not a fix to a build script)
33 | - `docs`: (changes to the documentation)
34 | - `style`: (formatting, missing semi colons, etc; no production code change)
35 | - `refactor`: (refactoring production code, eg. renaming a variable)
36 | - `test`: (adding missing tests, refactoring tests; no production code change)
37 | - `chore`: (updating grunt tasks etc; no production code change, e.g. dependencies upgrade)
38 | - `perf`: (performance improvement change, e.g. better concurrency performance)
39 | - `ci`: (updating CI configuration files and scripts e.g. `.gitHub/workflows/*.yml` )
40 |
41 | Example `` values:
42 |
43 | - `init`
44 | - `runner`
45 | - `watcher`
46 | - `config`
47 | - `web-server`
48 | - `proxy`
49 |
50 | The `` can be empty (e.g. if the change is a global or difficult to assign to a single component), in which case the parentheses are omitted. In smaller projects such as Karma plugins, the `` is empty.
51 |
52 | ## Message Subject (First Line)
53 |
54 | The first line cannot be longer than `72` characters and should be followed by a blank line. The type and scope should always be lowercase as shown below
55 |
56 | ## Message Body
57 |
58 | use as in the ``, use the imperative, present tense: "change" not "changed" nor "changes". Message body should include motivation for the change and contrasts with previous behavior.
59 |
60 | ## Message footer
61 |
62 | ### Referencing issues
63 |
64 | Closed issues should be listed on a separate line in the footer prefixed with "Closes" keyword as the following:
65 |
66 | ```
67 | Closes #234
68 | ```
69 |
70 | or in the case of multiple issues:
71 |
72 | ```
73 | Closes #123, #245, #992
74 | ```
75 |
76 | ## References
77 |
78 | -
79 | -
80 | -
81 | -
82 |
--------------------------------------------------------------------------------
/src/app/(auth)/login/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { zodResolver } from '@hookform/resolvers/zod'
4 | import { Button, Input } from '@nextui-org/react'
5 | import RiveComponent from '@rive-app/react-canvas'
6 | import ky, { HTTPError } from 'ky'
7 | import { useRouter } from 'next/navigation'
8 | import { useForm } from 'react-hook-form'
9 | import { useTranslation } from 'react-i18next'
10 | import { toast } from 'sonner'
11 | import { z } from 'zod'
12 | import { LogoText } from '~/components/LogoText'
13 | import { loginFormDefault, loginSchema } from '~/schemas/account'
14 |
15 | export default function LoginPage() {
16 | const { t } = useTranslation()
17 | const router = useRouter()
18 |
19 | const form = useForm>({
20 | resolver: zodResolver(loginSchema),
21 | defaultValues: loginFormDefault
22 | })
23 |
24 | return (
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
70 |
71 |
72 | )
73 | }
74 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "daed-revived-next",
3 | "version": "0.1.0",
4 | "private": true,
5 | "license": "MIT",
6 | "scripts": {
7 | "build": "next build",
8 | "dev": "next dev",
9 | "format": "prettier -w .",
10 | "lint": "next lint",
11 | "prepare": "husky install",
12 | "start": "next start",
13 | "test:unit": "vitest run"
14 | },
15 | "dependencies": {
16 | "@commitlint/config-conventional": "^18.4.3",
17 | "@graphql-codegen/cli": "^5.0.0",
18 | "@graphql-codegen/client-preset": "^4.1.0",
19 | "@graphql-codegen/introspection": "^4.0.0",
20 | "@graphql-typed-document-node/core": "^3.2.0",
21 | "@hookform/resolvers": "^3.3.2",
22 | "@monaco-editor/react": "^4.6.0",
23 | "@nextui-org/react": "^2.2.9",
24 | "@parcel/watcher": "^2.3.0",
25 | "@rive-app/react-canvas": "^4.5.4",
26 | "@tabler/icons-react": "^2.42.0",
27 | "@tanstack/react-query": "^5.12.1",
28 | "@tanstack/react-query-devtools": "^5.8.8",
29 | "@types/jsonwebtoken": "^9.0.5",
30 | "@types/lodash": "^4.14.202",
31 | "@types/mime": "^3.0.4",
32 | "@types/node": "^20.10.0",
33 | "@types/react": "^18.2.39",
34 | "@types/react-dom": "^18.2.17",
35 | "@types/react-syntax-highlighter": "^15.5.10",
36 | "@types/urijs": "^1.19.25",
37 | "@vitejs/plugin-react": "^4.2.0",
38 | "autoprefixer": "^10.4.16",
39 | "commitlint": "^18.4.3",
40 | "dayjs": "^1.11.10",
41 | "encoding": "^0.1.13",
42 | "env-paths": "^3.0.0",
43 | "eslint": "^8.54.0",
44 | "eslint-config-next": "14.0.3",
45 | "framer-motion": "^10.16.5",
46 | "geist": "^1.1.0",
47 | "graphql": "^16.8.1",
48 | "graphql-request": "^6.1.0",
49 | "husky": "^8.0.3",
50 | "i18next": "^23.7.7",
51 | "i18next-browser-languagedetector": "^7.2.0",
52 | "js-base64": "^3.7.5",
53 | "jsdom": "^23.0.0",
54 | "jsonwebtoken": "^9.0.2",
55 | "ky": "^1.1.3",
56 | "lint-staged": "^15.1.0",
57 | "lodash": "^4.17.21",
58 | "match-sorter": "^6.3.1",
59 | "mime": "^3.0.0",
60 | "monaco-editor": "^0.44.0",
61 | "monaco-themes": "^0.4.4",
62 | "next": "^14.0.3",
63 | "next-themes": "^0.2.1",
64 | "postcss": "^8.4.31",
65 | "prettier": "^3.1.0",
66 | "prettier-plugin-organize-imports": "^3.2.4",
67 | "prettier-plugin-tailwindcss": "^0.5.7",
68 | "qrcode.react": "^3.1.0",
69 | "react": "^18.2.0",
70 | "react-dom": "^18.2.0",
71 | "react-hook-form": "^7.48.2",
72 | "react-i18next": "^13.5.0",
73 | "react-syntax-highlighter": "^15.5.0",
74 | "react-use": "^17.4.2",
75 | "sonner": "^1.2.3",
76 | "sort-package-json": "^2.6.0",
77 | "tailwindcss": "^3.3.5",
78 | "typescript": "^5.3.2",
79 | "urijs": "^1.19.11",
80 | "vitest": "^0.34.6",
81 | "zod": "^3.22.4",
82 | "zod-i18n-map": "^2.21.0"
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contribute
2 |
3 | If you want to contribute to a project and make it better, your help is very welcome. Contributing is also a great way to learn more about social coding on Github, new technologies and and their ecosystems and how to make constructive, helpful bug reports, feature requests and the noblest of all contributions: a good, clean pull request.
4 |
5 | ### Bug Reports and Feature Requests
6 |
7 | If you have found a `bug` or have a `feature request`, please use the search first in case a similar issue already exists. If not, please create an [issue](https://github.com/daeuniverse/dae-wing/issues/new) in this repository
8 |
9 | ### Code
10 |
11 | If you would like to fix a bug or implement a feature, please `fork` the repository and `create a Pull Request`.
12 |
13 | Before you start any Pull Request, `it is recommended that you create an issue` to discuss first if you have any doubts about requirement or implementation. That way you can be sure that the maintainer(s) agree on what to change and how, and you can hopefully get a quick merge afterwards.
14 |
15 | `Pull Requests` can only be merged once all status checks are green.
16 |
17 | ### How to make a clean pull request
18 |
19 | - Create a `personal fork` of the project on Github.
20 | - Clone the fork on your local machine. Your remote repo on Github is called `origin`.
21 | - Add the original repository as a remote called `upstream`.
22 | - If you created your fork a while ago be sure to pull upstream changes into your local repository.
23 | - Create a new branch to work on! Branch from `develop` if it exists, else from `master`.
24 | - Implement/fix your feature, comment your code.
25 | - Follow the code style of the project, including indentation.
26 | - If the project has tests run them!
27 | - Write or adapt tests as needed.
28 | - Add or change the documentation as needed.
29 | - Squash your commits into a single commit with git's [interactive rebase](https://help.github.com/articles/interactive-rebase). Create a new branch if necessary.
30 | - Push your branch to your fork on Github, the remote `origin`.
31 | - From your fork open a pull request in the correct branch. Target the project's `develop` branch if there is one, else go for `master`!
32 | - Once the pull request is approved and merged you can pull the changes from `upstream` to your local repo and delete
33 | your extra branch(es).
34 |
35 | And last but not least: Always write your commit messages in the present tense. Your commit message should describe what the commit, when applied, does to the code – not what you did to the code.
36 |
37 | ### Re-requesting a review
38 |
39 | Please do not ping your reviewer(s) by mentioning them in a new comment. Instead, use the re-request review functionality. Read more about this in the [ GitHub docs, Re-requesting a review ](https://docs.github.com/en/free-pro-team@latest/github/collaborating-with-issues-and-pull-requests/incorporating-feedback-in-your-pull-request#re-requesting-a-review).
40 |
--------------------------------------------------------------------------------
/src/models/base.ts:
--------------------------------------------------------------------------------
1 | import URI from 'urijs'
2 | import { ZodSchema, z } from 'zod'
3 |
4 | import { NodeType } from '~/constants'
5 |
6 | export type GenerateURLParams = {
7 | username?: string
8 | password?: string
9 | protocol: string
10 | host: string
11 | port: number
12 | params?: Record
13 | hash: string
14 | path?: string
15 | }
16 |
17 | export abstract class BaseNodeResolver {
18 | abstract type: NodeType
19 | abstract schema: Schema
20 | abstract defaultValues: z.infer
21 |
22 | abstract generate(values: z.infer): string
23 | abstract resolve(url: string): z.infer
24 |
25 | generateURL = ({ username, password, protocol, host, port, params, hash, path }: GenerateURLParams) =>
26 | URI()
27 | .protocol(protocol || 'http')
28 | .username(username || '')
29 | .password(password || '')
30 | .host(host || '')
31 | .port(String(port) || '80')
32 | .path(path || '')
33 | .query(params || {})
34 | .hash(hash || '')
35 | .toString()
36 |
37 | parseURL(u: string) {
38 | let url = u
39 | let protocol = ''
40 | let fakeProto = false
41 |
42 | if (url.indexOf('://') === -1) {
43 | url = 'http://' + url
44 | } else {
45 | protocol = url.substring(0, url.indexOf('://'))
46 |
47 | switch (protocol) {
48 | case 'http':
49 | case 'https':
50 | case 'ws':
51 | case 'wss':
52 | break
53 | default:
54 | url = 'http' + url.substring(url.indexOf('://'))
55 | fakeProto = true
56 | }
57 | }
58 |
59 | const a = document.createElement('a')
60 | a.href = url
61 |
62 | const r: Record = {
63 | source: u,
64 | username: a.username,
65 | password: a.password,
66 | protocol: fakeProto ? protocol : a.protocol.replace(':', ''),
67 | host: a.hostname,
68 | port: a.port ? parseInt(a.port) : protocol === 'https' || protocol === 'wss' ? 443 : 80,
69 | query: a.search,
70 | params: (function () {
71 | const ret: Record = {},
72 | seg = a.search.replace(/^\?/, '').split('&'),
73 | len = seg.length
74 |
75 | let i = 0
76 |
77 | let s
78 |
79 | for (; i < len; i++) {
80 | if (!seg[i]) {
81 | continue
82 | }
83 |
84 | s = seg[i].split('=')
85 | ret[s[0]] = decodeURIComponent(s[1])
86 | }
87 |
88 | return ret
89 | })(),
90 | file: (a.pathname.match(/\/([^/?#]+)$/i) || [null, ''])[1],
91 | hash: a.hash.replace('#', ''),
92 | path: a.pathname.replace(/^([^/])/, '/$1'),
93 | relative: (a.href.match(/tps?:\/\/[^/]+(.+)/) || [null, ''])[1],
94 | segments: a.pathname.replace(/^\//, '').split('/')
95 | }
96 |
97 | a.remove()
98 |
99 | return r
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/src/apis/gql/fragment-masking.ts:
--------------------------------------------------------------------------------
1 | import { DocumentTypeDecoration, ResultOf, TypedDocumentNode } from '@graphql-typed-document-node/core'
2 | import { FragmentDefinitionNode } from 'graphql'
3 | import { Incremental } from './graphql'
4 |
5 | export type FragmentType> =
6 | TDocumentType extends DocumentTypeDecoration
7 | ? [TType] extends [{ ' $fragmentName'?: infer TKey }]
8 | ? TKey extends string
9 | ? { ' $fragmentRefs'?: { [key in TKey]: TType } }
10 | : never
11 | : never
12 | : never
13 |
14 | // return non-nullable if `fragmentType` is non-nullable
15 | export function useFragment(
16 | _documentNode: DocumentTypeDecoration,
17 | fragmentType: FragmentType>
18 | ): TType
19 | // return nullable if `fragmentType` is nullable
20 | export function useFragment(
21 | _documentNode: DocumentTypeDecoration,
22 | fragmentType: FragmentType> | null | undefined
23 | ): TType | null | undefined
24 | // return array of non-nullable if `fragmentType` is array of non-nullable
25 | export function useFragment(
26 | _documentNode: DocumentTypeDecoration,
27 | fragmentType: ReadonlyArray>>
28 | ): ReadonlyArray
29 | // return array of nullable if `fragmentType` is array of nullable
30 | export function useFragment(
31 | _documentNode: DocumentTypeDecoration,
32 | fragmentType: ReadonlyArray>> | null | undefined
33 | ): ReadonlyArray | null | undefined
34 | export function useFragment(
35 | _documentNode: DocumentTypeDecoration,
36 | fragmentType:
37 | | FragmentType>
38 | | ReadonlyArray>>
39 | | null
40 | | undefined
41 | ): TType | ReadonlyArray | null | undefined {
42 | return fragmentType as any
43 | }
44 |
45 | export function makeFragmentData, FT extends ResultOf>(
46 | data: FT,
47 | _fragment: F
48 | ): FragmentType {
49 | return data as FragmentType
50 | }
51 | export function isFragmentReady(
52 | queryNode: DocumentTypeDecoration,
53 | fragmentNode: TypedDocumentNode,
54 | data: FragmentType, any>> | null | undefined
55 | ): data is FragmentType {
56 | const deferredFields = (queryNode as { __meta__?: { deferredFields: Record } }).__meta__
57 | ?.deferredFields
58 |
59 | if (!deferredFields) return true
60 |
61 | const fragDef = fragmentNode.definitions[0] as FragmentDefinitionNode | undefined
62 | const fragName = fragDef?.name?.value
63 |
64 | const fields = (fragName && deferredFields[fragName]) || []
65 | return fields.length > 0 && fields.every((field) => data && field in data)
66 | }
67 |
--------------------------------------------------------------------------------
/src/schemas/config.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 |
3 | export const configFormSchema = z.object({
4 | name: z.string().min(4).max(20),
5 | tproxyPort: z.number().min(0).max(65535),
6 | tproxyPortProtect: z.boolean(),
7 | soMarkFromDae: z.number().min(0),
8 | logLevel: z.string(),
9 | disableWaitingNetwork: z.boolean(),
10 | lanInterface: z.array(z.string()),
11 | wanInterface: z.array(z.string()),
12 | autoConfigKernelParameter: z.boolean(),
13 | tcpCheckUrl: z.array(z.string().min(1)).min(1),
14 | tcpCheckHttpMethod: z.string(),
15 | udpCheckDns: z.array(z.string().min(1)).min(1),
16 | checkIntervalSeconds: z.number().min(0),
17 | checkToleranceMS: z.number().min(0),
18 | dialMode: z.string(),
19 | allowInsecure: z.boolean(),
20 | sniffingTimeoutMS: z.number().min(0),
21 | tlsImplementation: z.string(),
22 | utlsImitate: z.string()
23 | })
24 |
25 | export enum LogLevel {
26 | error = 'error',
27 | warn = 'warn',
28 | info = 'info',
29 | debug = 'debug',
30 | trace = 'trace'
31 | }
32 |
33 | export enum DialMode {
34 | ip = 'ip',
35 | domain = 'domain',
36 | domainP = 'domain+',
37 | domainPP = 'domain++'
38 | }
39 |
40 | export enum TcpCheckHttpMethod {
41 | CONNECT = 'CONNECT',
42 | HEAD = 'HEAD',
43 | OPTIONS = 'OPTIONS',
44 | TRACE = 'TRACE',
45 | GET = 'GET',
46 | POST = 'POST',
47 | DELETE = 'DELETE',
48 | PATCH = 'PATCH',
49 | PUT = 'PUT'
50 | }
51 |
52 | export enum TLSImplementation {
53 | tls = 'tls',
54 | utls = 'utls'
55 | }
56 |
57 | export enum UTLSImitate {
58 | randomized = 'randomized',
59 | randomizedalpn = 'randomizedalpn',
60 | randomizednoalpn = 'randomizednoalpn',
61 | firefox_auto = 'firefox_auto',
62 | firefox_55 = 'firefox_55',
63 | firefox_56 = 'firefox_56',
64 | firefox_63 = 'firefox_63',
65 | firefox_65 = 'firefox_65',
66 | firefox_99 = 'firefox_99',
67 | firefox_102 = 'firefox_102',
68 | firefox_105 = 'firefox_105',
69 | chrome_auto = 'chrome_auto',
70 | chrome_58 = 'chrome_58',
71 | chrome_62 = 'chrome_62',
72 | chrome_70 = 'chrome_70',
73 | chrome_72 = 'chrome_72',
74 | chrome_83 = 'chrome_83',
75 | chrome_87 = 'chrome_87',
76 | chrome_96 = 'chrome_96',
77 | chrome_100 = 'chrome_100',
78 | chrome_102 = 'chrome_102',
79 | ios_auto = 'ios_auto',
80 | ios_11_1 = 'ios_11_1',
81 | ios_12_1 = 'ios_12_1',
82 | ios_13 = 'ios_13',
83 | ios_14 = 'ios_14',
84 | android_11_okhttp = 'android_11_okhttp',
85 | edge_auto = 'edge_auto',
86 | edge_85 = 'edge_85',
87 | edge_106 = 'edge_106',
88 | safari_auto = 'safari_auto',
89 | safari_16_0 = 'safari_16_0',
90 | utls_360_auto = '360_auto',
91 | utls_360_7_5 = '360_7_5',
92 | utls_360_11_0 = '360_11_0',
93 | qq_auto = 'qq_auto',
94 | qq_11_1 = 'qq_11_1'
95 | }
96 |
97 | export const configFormDefault: z.infer = {
98 | name: '',
99 | tproxyPort: 12345,
100 | tproxyPortProtect: true,
101 | soMarkFromDae: 80,
102 | logLevel: LogLevel.info,
103 | disableWaitingNetwork: true,
104 | lanInterface: [],
105 | wanInterface: [],
106 | autoConfigKernelParameter: true,
107 | tcpCheckUrl: ['http://cp.cloudflare.com', '1.1.1.1', '2606:4700:4700::1111'],
108 | tcpCheckHttpMethod: TcpCheckHttpMethod.HEAD,
109 | udpCheckDns: ['dns.google.com:53', '8.8.8.8', '2001:4860:4860::8888'],
110 | checkIntervalSeconds: 30,
111 | checkToleranceMS: 0,
112 | dialMode: DialMode.domain,
113 | allowInsecure: false,
114 | sniffingTimeoutMS: 100,
115 | tlsImplementation: TLSImplementation.tls,
116 | utlsImitate: UTLSImitate.randomized
117 | }
118 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # daed
2 |
3 | A modern web dashboard for dae
4 |
5 | 
6 | 
7 | 
8 | 
9 | 
10 |
11 | ## Preview
12 |
13 | 
14 |
15 | ## Features
16 |
17 | - [x] Easy to use, with keyboard navigation / shortcuts builtin
18 | - [x] Beautiful and intuitive UI
19 | - [x] Light / Dark mode
20 | - [x] Mobile friendly
21 |
22 | ## Getting started
23 |
24 | ### Prerequisites
25 |
26 | #### Install the toolchain
27 |
28 | > Git is required to fetch the source code
29 |
30 | - [Git](https://git-scm.com)
31 |
32 | > Install the docker engine if you choose to build and run in docker
33 |
34 | - [Docker](https://www.docker.com)
35 |
36 | > Install the build toolchain if you choose to build from the source files on your own
37 |
38 | - [Golang](https://go.dev), [GNU GCC](https://gcc.gnu.org) (required by dae-wing)
39 | - [Node.js](https://nodejs.org), [pnpm](https://pnpm.io) (required by daed)
40 |
41 | #### Fetch the source code
42 |
43 | > Clone the repository with git submodules (dae-wing) using git
44 |
45 | ```shell
46 | git clone https://github.com/daeuniverse/daed-revived-next.git daed
47 | cd daed
48 |
49 | # Initialize git submodules
50 | git submodule update --init --recursive
51 | ```
52 |
53 | #### Build and run dae-wing
54 |
55 | > Build dae-wing
56 |
57 | ```shell
58 | cd wing
59 |
60 | make deps
61 | go build -o dae-wing
62 | ```
63 |
64 | > Run dae-wing with root privileges
65 |
66 | ```shell
67 | sudo ./dae-wing run -c ./
68 | ```
69 |
70 | Learn more about dae-wing at [dae-wing](https://github.com/daeuniverse/dae-wing)
71 |
72 | ### Customize daed with `.env.local` file
73 |
74 | Create a `.env.local` file to customize daed
75 |
76 | | Name | Default | Required | Description |
77 | | ------------ | --------------------- | -------- | ------------------------------------ |
78 | | WING_API_URL | http://localhost:2023 | ✅ | Set the API Endpoint URL of dae-wing |
79 | | HOSTNAME | 0.0.0.0 | ⬜ | Set the HTTP Hostname of daed |
80 | | PORT | 3000 | ⬜ | Set the HTTP Port of daed |
81 |
82 | ### Docker
83 |
84 | > Build the docker image
85 |
86 | ```shell
87 | docker build -t daed .
88 | ```
89 |
90 | > Run the docker image you just build in the background
91 |
92 | ```shell
93 | docker run -d --name daed -p 3000:3000 daed
94 | ```
95 |
96 | ### From Source
97 |
98 | > Install Dependencies
99 |
100 | ```shell
101 | pnpm install
102 | ```
103 |
104 | > Build Artifacts
105 |
106 | ```shell
107 | pnpm run build
108 | ```
109 |
110 | > Run Server
111 |
112 | ```shell
113 | pnpm start
114 | ```
115 |
116 | ## Contributing
117 |
118 | Feel free to open issues or submit your PR, any feedbacks or help are greatly appreciated.
119 |
120 | Special thanks go to all these amazing people.
121 |
122 | [](https://github.com/daeuniverse/daed-revived-next/graphs/contributors)
123 |
124 | If you would like to contribute, please see the [instructions](CONTRIBUTING.md). Also, it is recommended following the [commit message guide](docs/commit-msg-guide.md).
125 |
126 | ## Credits
127 |
128 | - [dae-wing](https://github.com/daeuniverse/dae-wing)
129 | - [Next.JS](https://github.com/vercel/next.js)
130 | - [NextUI](https://github.com/nextui-org/nextui)
131 |
--------------------------------------------------------------------------------
/src/models/resolvers/shadowsocks.ts:
--------------------------------------------------------------------------------
1 | import { Base64 } from 'js-base64'
2 | import { z } from 'zod'
3 | import { NodeType } from '~/constants'
4 | import { BaseNodeResolver } from '~/models'
5 | import { ssDefault, ssSchema } from '~/schemas/node'
6 |
7 | export class ShadowsocksNodeResolver extends BaseNodeResolver {
8 | type = NodeType.shadowsocks
9 | schema = ssSchema
10 | defaultValues = ssDefault
11 |
12 | generate(values: z.infer) {
13 | /* ss://BASE64(method:password)@server:port#name */
14 | let link = `ss://${Base64.encode(`${values.method}:${values.password}`)}@${values.server}:${values.port}/`
15 |
16 | if (values.plugin) {
17 | const plugin: string[] = [values.plugin]
18 |
19 | if (values.plugin === 'v2ray-plugin') {
20 | if (values.tls) {
21 | plugin.push('tls')
22 | }
23 |
24 | if (values.mode !== 'websocket') {
25 | plugin.push('mode=' + values.mode)
26 | }
27 |
28 | if (values.host) {
29 | plugin.push('host=' + values.host)
30 | }
31 |
32 | if (values.path) {
33 | if (!values.path.startsWith('/')) {
34 | values.path = '/' + values.path
35 | }
36 |
37 | plugin.push('path=' + values.path)
38 | }
39 |
40 | if (values.impl) {
41 | plugin.push('impl=' + values.impl)
42 | }
43 | } else {
44 | plugin.push('obfs=' + values.obfs)
45 | plugin.push('obfs-host=' + values.host)
46 |
47 | if (values.obfs === 'http') {
48 | plugin.push('obfs-path=' + values.path)
49 | }
50 |
51 | if (values.impl) {
52 | plugin.push('impl=' + values.impl)
53 | }
54 | }
55 |
56 | link += `?plugin=${encodeURIComponent(plugin.join(';'))}`
57 | }
58 |
59 | link += values.name.length ? `#${encodeURIComponent(values.name)}` : ''
60 |
61 | return link
62 | }
63 |
64 | resolve(url: string) {
65 | const u = this.parseURL(url)
66 |
67 | let mp
68 |
69 | if (!u.password) {
70 | try {
71 | u.username = Base64.decode(decodeURIComponent(u.username))
72 | mp = u.username.split(':')
73 |
74 | if (mp.length > 2) {
75 | mp[1] = mp.slice(1).join(':')
76 | mp = mp.slice(0, 2)
77 | }
78 | } catch (e) {
79 | //pass
80 | }
81 | } else {
82 | mp = [u.username, u.password]
83 | }
84 |
85 | u.hash = decodeURIComponent(u.hash)
86 |
87 | const obj: z.infer = {
88 | method: mp[0],
89 | password: mp[1],
90 | server: u.host,
91 | port: u.port,
92 | name: u.hash,
93 | obfs: 'http',
94 | plugin: '',
95 | impl: '',
96 | path: '',
97 | tls: '',
98 | mode: '',
99 | host: ''
100 | }
101 |
102 | if (u.params.plugin) {
103 | u.params.plugin = decodeURIComponent(u.params.plugin)
104 |
105 | const arr = u.params.plugin.split(';')
106 |
107 | const plugin = arr[0]
108 |
109 | switch (plugin) {
110 | case 'obfs-local':
111 | case 'simpleobfs':
112 | obj.plugin = 'simple-obfs'
113 | break
114 | case 'v2ray-plugin':
115 | obj.tls = ''
116 | obj.mode = 'websocket'
117 | break
118 | }
119 |
120 | for (let i = 1; i < arr.length; i++) {
121 | //"obfs-local;obfs=tls;obfs-host=4cb6a43103.wns.windows.com"
122 | const a = arr[i].split('=')
123 |
124 | switch (a[0]) {
125 | case 'obfs':
126 | obj.obfs = a[1]
127 | break
128 | case 'host':
129 | case 'obfs-host':
130 | obj.host = a[1]
131 | break
132 | case 'path':
133 | case 'obfs-path':
134 | obj.path = a[1]
135 | break
136 | case 'mode':
137 | obj.mode = a[1]
138 | break
139 | case 'tls':
140 | obj.tls = 'tls'
141 | break
142 | case 'impl':
143 | obj.impl = a[1]
144 | }
145 | }
146 | }
147 |
148 | return obj
149 | }
150 | }
151 |
--------------------------------------------------------------------------------
/src/app/api/login/route.ts:
--------------------------------------------------------------------------------
1 | import { ClientError, GraphQLClient } from 'graphql-request'
2 | import { NextResponse } from 'next/server'
3 | import { graphql } from '~/apis/gql'
4 | import {
5 | createConfigMutation,
6 | createDNSMutation,
7 | createGroupMutation,
8 | createRoutingMutation,
9 | selectConfigMutation,
10 | selectDNSMutation,
11 | selectRoutingMutation,
12 | setJsonStorageMutation
13 | } from '~/apis/mutation'
14 | import { graphqlAPIURL } from '~/constants'
15 | import { storeJWTAsCookie } from '~/helpers'
16 | import { configFormDefault } from '~/schemas/config'
17 | import { DNSFormDefault } from '~/schemas/dns'
18 | import { groupFormDefault } from '~/schemas/group'
19 | import { routingFormDefault } from '~/schemas/routing'
20 |
21 | const getDefaults = async >(requestClient: GraphQLClient, paths: string[]) => {
22 | const { jsonStorage } = await requestClient.request(
23 | graphql(`
24 | query JsonStorage($paths: [String!]) {
25 | jsonStorage(paths: $paths)
26 | }
27 | `),
28 | { paths: paths as unknown as string[] }
29 | )
30 |
31 | return jsonStorage.reduce(
32 | (prev, cur, index) => ({ ...prev, [paths[index]]: cur }),
33 | {} as { [key in T[number]]: (typeof jsonStorage)[number] }
34 | )
35 | }
36 |
37 | const setJsonStorage = (requestClient: GraphQLClient, object: Record) => {
38 | const paths = Object.keys(object)
39 | const values = paths.map((path) => object[path])
40 |
41 | return requestClient.request(setJsonStorageMutation, {
42 | paths,
43 | values
44 | })
45 | }
46 |
47 | const initialize = async (token: string) => {
48 | const requestClient = new GraphQLClient(graphqlAPIURL, {
49 | headers: {
50 | Authorization: `Bearer ${token}`
51 | }
52 | })
53 |
54 | const { defaultConfigID, defaultRoutingID, defaultDNSID, defaultGroupID } = await getDefaults(requestClient, [
55 | 'defaultConfigID',
56 | 'defaultDNSID',
57 | 'defaultGroupID',
58 | 'defaultRoutingID'
59 | ])
60 |
61 | if (!defaultConfigID) {
62 | const {
63 | createConfig: { id }
64 | } = await requestClient.request(createConfigMutation, {
65 | name: 'global',
66 | global: configFormDefault
67 | })
68 |
69 | await requestClient.request(selectConfigMutation, { id })
70 | await setJsonStorage(requestClient, { defaultConfigID: id })
71 | }
72 |
73 | if (!defaultRoutingID) {
74 | const {
75 | createRouting: { id }
76 | } = await requestClient.request(createRoutingMutation, { ...routingFormDefault, name: 'routing' })
77 |
78 | await requestClient.request(selectRoutingMutation, { id })
79 | await setJsonStorage(requestClient, { defaultRoutingID: id })
80 | }
81 |
82 | if (!defaultDNSID) {
83 | const {
84 | createDns: { id }
85 | } = await requestClient.request(createDNSMutation, { ...DNSFormDefault, name: 'dns' })
86 |
87 | await requestClient.request(selectDNSMutation, { id })
88 | await setJsonStorage(requestClient, { defaultDNSID: id })
89 | }
90 |
91 | if (!defaultGroupID) {
92 | const {
93 | createGroup: { id }
94 | } = await requestClient.request(createGroupMutation, {
95 | ...groupFormDefault,
96 | name: 'proxy'
97 | })
98 | await setJsonStorage(requestClient, { defaultGroupID: id })
99 | }
100 | }
101 |
102 | export const POST = async (req: Request) => {
103 | const requestClient = new GraphQLClient(graphqlAPIURL, {
104 | responseMiddleware: (response) => {
105 | const error = (response as ClientError).response?.errors?.[0]
106 |
107 | if (!error) {
108 | return response
109 | }
110 |
111 | throw error
112 | }
113 | })
114 |
115 | const { username, password } = await req.json()
116 |
117 | const { numberUsers } = await requestClient.request(
118 | graphql(`
119 | query NumberUsers {
120 | numberUsers
121 | }
122 | `)
123 | )
124 |
125 | // If there are no users, create one
126 | // and initialize the default config, routing, dns, and group
127 | if (numberUsers === 0) {
128 | const { createUser } = await requestClient.request(
129 | graphql(`
130 | mutation CreateUser($username: String!, $password: String!) {
131 | createUser(username: $username, password: $password)
132 | }
133 | `),
134 | { username, password }
135 | )
136 |
137 | await initialize(createUser)
138 |
139 | storeJWTAsCookie(createUser)
140 |
141 | return new NextResponse()
142 | }
143 |
144 | try {
145 | const { token } = await requestClient.request(
146 | graphql(`
147 | query Token($username: String!, $password: String!) {
148 | token(username: $username, password: $password)
149 | }
150 | `),
151 | { username, password }
152 | )
153 |
154 | storeJWTAsCookie(token)
155 |
156 | return new NextResponse()
157 | } catch (err) {
158 | return NextResponse.json({ message: (err as Error).message }, { status: 401 })
159 | }
160 | }
161 |
--------------------------------------------------------------------------------
/src/i18n/locales/zh-Hans.json:
--------------------------------------------------------------------------------
1 | {
2 | "actions": {
3 | "cancel": "取消",
4 | "confirm": "确定",
5 | "darkMode": "暗黑",
6 | "edit": "编辑",
7 | "lightMode": "明亮",
8 | "loading": "正在加载{{resourceName}}...",
9 | "login": "登录",
10 | "logout": "登出",
11 | "refresh": "刷新",
12 | "remove": "移除",
13 | "reset": "重置",
14 | "select": "选择",
15 | "submit": "提交",
16 | "switchLanguage": "切换语言",
17 | "switchTheme": "切换主题",
18 | "systemMode": "系统",
19 | "update": "修改{{resourceName}}"
20 | },
21 | "form": {
22 | "descriptions": {
23 | "allowInsecure": "允许使用不安全的TLS证书。除非迫不得已,否则不建议打开它",
24 | "autoConfigKernelParameter": "自动配置 Linux 内核参数,如 ip_forward(路由转发)和 send_redirects(发送重定向)",
25 | "checkTolerance": "只有当 新的延迟 <= (旧的延迟 - 公差) 时,群组才会切换节点",
26 | "disableWaitingNetwork": "禁用在拉取订阅之前等待网络",
27 | "group": {
28 | "Min": "从组中为每个连接选择最后延迟最小的节点",
29 | "MinAvg10": "从组中为每个连接选择最近 10 个延迟的最小平均值的节点",
30 | "MinMovingAvg": "从组中为每个连接选择具有最小移动平均延迟的节点",
31 | "Random": "为每个连接从组中随机选择一个节点"
32 | },
33 | "lanInterface": "要绑定的 LAN 接口。如果您想代理局域网,请使用它",
34 | "pleaseEnter": "请输入您的{{fieldName}}",
35 | "pleaseSelect": "请选择您的{{fieldName}}",
36 | "sniffingTimeout": "等待第一次发送数据以进行嗅探时超时。如果拨号模式为 ip,则始终为 0。将其设置得更高在高延迟局域网网络中很有用",
37 | "soMarkFromDae": "如果不是 0,则从 dae 发送的流量将被设置套接字标记(SO_MARK)。这对于使用 iptables/nftables 透明代理规则避免流量循环非常有用",
38 | "tcpCheckHttpMethod": "对 TCP 检测链接的 HTTP 请求方法。默认情况下使用 HEAD,因为某些服务器实现绕过了对此类流量的核算",
39 | "tcpCheckUrl": "如果您在本地有 Dual Stack(双协议栈),URL 的域名解析应该同时具有 IPV4 和 IPV6。第一个是 URL,其它是 IP 地址(如果给定的话)。考虑到流量消耗,建议选择具有 Anycast IP 且响应较少的站点",
40 | "tlsImplementation": "TLS 实现。tls 是使用 GO 的 crypto/tls。utls 是使用 utls,它可以模仿浏览器的客户端 Hello",
41 | "tproxyPort": "要监听的透明代理端口。合法范围是 0 - 65535。它不是 HTTP/SOCKS 端口,仅由 eBPF 程序使用。在正常情况下,您不需要使用它",
42 | "tproxyPortProtect": "将其设置为 true 可保护透明代理端口免受未经请求的流量的影响。将其设置为 false 以允许用户使用自我管理的 iptables 透明代理规则",
43 | "udpCheckDns": "此 DNS 将用于检查节点的 UDP 连接。如果下面的 DNS 上游包含 TCP,它也可以用于检查节点的 TCP DNS 连接。第一个是 URL,其它是 IP 地址(如果给定的话)。如果您在本地有 Dual Stack(双协议栈),则此 DNS 应该同时具有 IPV4 和 IPV6",
44 | "utlsImitate": "要模仿的 uTLS 的客户端 Hello ID。只有当 TLS 实现 为 utls 时,此操作才会生效",
45 | "wanInterface": "要绑定的 WAN 接口。如果您想代理本机,请使用它"
46 | },
47 | "errors": {
48 | "passwordDontMatch": "密码不匹配"
49 | },
50 | "fields": {
51 | "allowInsecure": "允许不安全",
52 | "autoConfigKernelParameter": "自动配置内核参数",
53 | "checkInterval": "检测间隔",
54 | "checkTolerance": "检测公差",
55 | "confirmPassword": "确认密码",
56 | "currentPassword": "当前密码",
57 | "dialMode": "拨号模式",
58 | "dialModes": {
59 | "domain": "通过嗅探使用域名拨号代理。如果 DNS 环境不纯净,这将在很大程度上缓解 DNS 污染问题。通常,这种模式会带来更快的代理响应时间,因为代理会在远程重新解析域名,从而获得更好的 IP 连接结果。此策略不影响路由。也就是说,域重写将在流量拆分后进行路由,dae 不会对其进行重新路由",
60 | "domain+": "基于 domain 模式但不检查嗅探域名的真实性。对于那些 DNS 请求不通过 dae 但想要更快的代理响应时间的用户来说,这很有用。请注意,如果 DNS 请求不通过 dae,dae 就无法按域划分流量",
61 | "domain++": "基于 domain+ 模式,但强制使用嗅探域重新路由流量,以部分恢复基于域的流量拆分能力。它不适用于直接流量,并且会消耗更多的 CPU 资源",
62 | "ip": "直接使用来自 DNS 的 IP 拨号代理。这允许您的 IPV4、IPV6 分别选择最佳路径,并使应用程序请求的 IP 版本符合预期。例如,如果您使用 curl -4 ip.sb,您将通过代理请求 IPv4 并获得 IPv4 回显。curl -6 ip.sb 将请求 IPV6。如果你是你的节点支持的话,这可能会解决一些怪异的全锥形问题。在此模式下将禁用嗅探"
63 | },
64 | "disableWaitingNetwork": "禁用网络等待",
65 | "lanInterface": "LAN 接口",
66 | "link": "链接",
67 | "logLevel": "日志等级",
68 | "logLevels": {
69 | "debug": "调试",
70 | "error": "错误",
71 | "info": "信息",
72 | "trace": "追踪",
73 | "warn": "警告"
74 | },
75 | "name": "名称",
76 | "newPassword": "新密码",
77 | "password": "密码",
78 | "sniffingTimeout": "嗅探超时",
79 | "soMarkFromDae": "为 dae 设置套接字标记",
80 | "tag": "标签",
81 | "tcpCheckHttpMethod": "TCP 检测 HTTP 方式",
82 | "tcpCheckUrl": "TCP 检测链接",
83 | "tlsImplementation": "TLS 实现",
84 | "tproxyPort": "透明代理端口",
85 | "tproxyPortProtect": "透明代理端口保护",
86 | "udpCheckDns": "UDP 检测 DNS",
87 | "username": "用户名",
88 | "utlsImitate": "uTLS 模仿",
89 | "wanInterface": "WAN 接口"
90 | }
91 | },
92 | "primitives": {
93 | "accountName": "用户名:{{accountName}}",
94 | "accountSettings": "账户设置",
95 | "action": "动作",
96 | "address": "地址",
97 | "autoDetect": "自动检测",
98 | "chineseSimplified": "简中",
99 | "config": "配置",
100 | "connectingOptions": "连接选项",
101 | "create": "创建{{resourceName}}",
102 | "default": "默认",
103 | "dns": "域名解析",
104 | "endpointURL": "接口地址",
105 | "english": "英文",
106 | "general": "常规",
107 | "group": "群组",
108 | "interfaceAndKernelOptions": "接口及内核选项",
109 | "millisecond": "毫秒",
110 | "name": "名称",
111 | "network": "网络",
112 | "node": "节点",
113 | "nodeConnectivityCheck": "节点连通性检测",
114 | "policy": "策略",
115 | "profile": "个人资料",
116 | "protocol": "协议",
117 | "remove": "移除{{resourceName}}",
118 | "routing": "路由",
119 | "rule": "规则",
120 | "second": "秒",
121 | "settings": "设置",
122 | "softwareOptions": "软件选项",
123 | "subscription": "订阅",
124 | "tag": "标签",
125 | "updatedAt": "更新时间",
126 | "username": "登录账户:{{username}}"
127 | }
128 | }
129 |
--------------------------------------------------------------------------------
/src/schemas/node.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 |
3 | export const nodeFormSchema = z.object({
4 | nodes: z
5 | .array(
6 | z.object({
7 | tag: z.string().min(1),
8 | link: z.string().min(1)
9 | })
10 | )
11 | .min(1)
12 | })
13 |
14 | export const nodeFormDefault: z.infer = {
15 | nodes: [{ tag: '', link: '' }]
16 | }
17 |
18 | export const v2raySchema = z.object({
19 | ps: z.string(),
20 | add: z.string().min(1),
21 | port: z.number().min(0).max(65535),
22 | id: z.string().min(1),
23 | aid: z.number().min(0).max(65535),
24 | net: z.enum(['tcp', 'kcp', 'ws', 'h2', 'grpc']),
25 | type: z.enum(['none', 'http', 'srtp', 'utp', 'wechat-video', 'dtls', 'wireguard']),
26 | host: z.string(),
27 | path: z.string(),
28 | tls: z.enum(['none', 'tls']),
29 | flow: z.enum(['none', 'xtls-rprx-origin', 'xtls-rprx-origin-udp443', 'xtls-rprx-vision', 'xtls-rprx-vision-udp443']),
30 | alpn: z.string(),
31 | scy: z.enum(['auto', 'aes-128-gcm', 'chacha20-poly1305', 'none', 'zero']),
32 | v: z.literal(''),
33 | allowInsecure: z.boolean(),
34 | sni: z.string()
35 | })
36 |
37 | export const v2rayDefault: z.infer = {
38 | type: 'none',
39 | tls: 'none',
40 | net: 'tcp',
41 | scy: 'auto',
42 | add: '',
43 | aid: 0,
44 | allowInsecure: false,
45 | alpn: '',
46 | flow: 'none',
47 | host: '',
48 | id: '',
49 | path: '',
50 | port: 0,
51 | ps: '',
52 | v: '',
53 | sni: ''
54 | }
55 |
56 | export const ssSchema = z.object({
57 | method: z.enum(['aes-128-gcm', 'aes-256-gcm', 'chacha20-poly1305', 'chacha20-ietf-poly1305', 'plain', 'none']),
58 | plugin: z.enum(['', 'simple-obfs', 'v2ray-plugin']),
59 | obfs: z.enum(['http', 'tls']),
60 | tls: z.enum(['', 'tls']),
61 | path: z.string(),
62 | mode: z.string(),
63 | host: z.string(),
64 | password: z.string().min(1),
65 | server: z.string().min(1),
66 | port: z.number().min(0).max(65535),
67 | name: z.string(),
68 | impl: z.enum(['', 'chained', 'transport'])
69 | })
70 |
71 | export const ssDefault: z.infer = {
72 | plugin: '',
73 | method: 'aes-128-gcm',
74 | obfs: 'http',
75 | host: '',
76 | impl: '',
77 | mode: '',
78 | name: '',
79 | password: '',
80 | path: '',
81 | port: 0,
82 | server: '',
83 | tls: ''
84 | }
85 |
86 | export const ssrSchema = z.object({
87 | method: z.enum([
88 | 'aes-128-cfb',
89 | 'aes-192-cfb',
90 | 'aes-256-cfb',
91 | 'aes-128-ctr',
92 | 'aes-192-ctr',
93 | 'aes-256-ctr',
94 | 'aes-128-ofb',
95 | 'aes-192-ofb',
96 | 'aes-256-ofb',
97 | 'des-cfb',
98 | 'bf-cfb',
99 | 'cast5-cfb',
100 | 'rc4-md5',
101 | 'chacha20-ietf',
102 | 'salsa20',
103 | 'camellia-128-cfb',
104 | 'camellia-192-cfb',
105 | 'camellia-256-cfb',
106 | 'idea-cfb',
107 | 'rc2-cfb',
108 | 'seed-cfb',
109 | 'none'
110 | ]),
111 | password: z.string().min(1),
112 | server: z.string().min(1),
113 | port: z.number().min(0).max(65535).positive(),
114 | name: z.string(),
115 | proto: z.enum([
116 | 'origin',
117 | 'verify_sha1',
118 | 'auth_sha1_v4',
119 | 'auth_aes128_md5',
120 | 'auth_aes128_sha1',
121 | 'auth_chain_a',
122 | 'auth_chain_b'
123 | ]),
124 | protoParam: z.string(),
125 | obfs: z.enum(['plain', 'http_simple', 'http_post', 'random_head', 'tls1.2_ticket_auth']),
126 | obfsParam: z.string()
127 | })
128 |
129 | export const ssrDefault: z.infer = {
130 | method: 'aes-128-cfb',
131 | proto: 'origin',
132 | obfs: 'plain',
133 | name: '',
134 | obfsParam: '',
135 | password: '',
136 | port: 0,
137 | protoParam: '',
138 | server: ''
139 | }
140 |
141 | export const trojanSchema = z.object({
142 | name: z.string(),
143 | server: z.string().min(1),
144 | peer: z.string(),
145 | host: z.string(),
146 | path: z.string(),
147 | allowInsecure: z.boolean(),
148 | port: z.number().min(0).max(65535),
149 | password: z.string().min(1),
150 | method: z.enum(['origin', 'shadowsocks']),
151 | ssCipher: z.enum(['aes-128-gcm', 'aes-256-gcm', 'chacha20-poly1305', 'chacha20-ietf-poly1305']),
152 | ssPassword: z.string(),
153 | obfs: z.enum(['none', 'websocket'])
154 | })
155 |
156 | export const trojanDefault: z.infer = {
157 | method: 'origin',
158 | obfs: 'none',
159 | allowInsecure: false,
160 | host: '',
161 | name: '',
162 | password: '',
163 | path: '',
164 | peer: '',
165 | port: 0,
166 | server: '',
167 | ssCipher: 'aes-128-gcm',
168 | ssPassword: ''
169 | }
170 |
171 | export const tuicSchema = z.object({
172 | name: z.string(),
173 | server: z.string().min(1),
174 | port: z.number().min(0).max(65535),
175 | uuid: z.string().min(1),
176 | password: z.string().min(1),
177 | allowInsecure: z.boolean(),
178 | disable_sni: z.boolean(),
179 | sni: z.string(),
180 | congestion_control: z.string(),
181 | alpn: z.string(),
182 | udp_relay_mode: z.string()
183 | })
184 |
185 | export const tuicDefault: z.infer = {
186 | name: '',
187 | port: 0,
188 | server: '',
189 | alpn: '',
190 | congestion_control: '',
191 | disable_sni: false,
192 | allowInsecure: false,
193 | uuid: '',
194 | password: '',
195 | udp_relay_mode: '',
196 | sni: ''
197 | }
198 |
199 | export const juicitySchema = z.object({
200 | name: z.string(),
201 | server: z.string().min(1),
202 | port: z.number().min(0).max(65535),
203 | uuid: z.string().min(1),
204 | password: z.string().min(1),
205 | allowInsecure: z.boolean(),
206 | sni: z.string(),
207 | congestion_control: z.string()
208 | })
209 |
210 | export const juicityDefault: z.infer = {
211 | name: '',
212 | port: 0,
213 | server: '',
214 | congestion_control: '',
215 | allowInsecure: false,
216 | uuid: '',
217 | password: '',
218 | sni: ''
219 | }
220 |
221 | export const httpSchema = z.object({
222 | username: z.string(),
223 | password: z.string(),
224 | host: z.string().min(1),
225 | port: z.number().min(0).max(65535),
226 | name: z.string()
227 | })
228 |
229 | export const httpDefault: z.infer = {
230 | host: '',
231 | name: '',
232 | password: '',
233 | port: 0,
234 | username: ''
235 | }
236 |
237 | export const socks5Schema = z.object({
238 | username: z.string(),
239 | password: z.string(),
240 | host: z.string().min(1),
241 | port: z.number().min(0).max(65535),
242 | name: z.string()
243 | })
244 |
245 | export const socks5Default: z.infer = {
246 | host: '',
247 | name: '',
248 | password: '',
249 | port: 0,
250 | username: ''
251 | }
252 |
--------------------------------------------------------------------------------
/src/i18n/locales/en-US.json:
--------------------------------------------------------------------------------
1 | {
2 | "actions": {
3 | "cancel": "Cancel",
4 | "confirm": "Confirm",
5 | "darkMode": "Dark",
6 | "edit": "Edit",
7 | "lightMode": "Light",
8 | "loading": "Loading {{resourceName}}...",
9 | "login": "Login",
10 | "logout": "Logout",
11 | "refresh": "Refresh",
12 | "remove": "Remove",
13 | "reset": "Reset",
14 | "select": "Select",
15 | "submit": "Submit",
16 | "switchLanguage": "Switch Language",
17 | "switchTheme": "Switch Theme",
18 | "systemMode": "System",
19 | "update": "Update {{resourceName}}"
20 | },
21 | "form": {
22 | "descriptions": {
23 | "allowInsecure": "Allow insecure TLS certificates. It is not recommended to turn it on unless you have to.",
24 | "autoConfigKernelParameter": "Automatically configure Linux kernel parameters like ip_forward and send_redirects",
25 | "checkTolerance": "Group will switch node only when new_latency <= (old_latency - tolerance)",
26 | "disableWaitingNetwork": "Disable waiting for network before pulling subscriptions",
27 | "group": {
28 | "Min": "Select the node with min last latency from the group for every connection",
29 | "MinAvg10": "Select the node with min average of the last 10 latencies from the group for every connection",
30 | "MinMovingAvg": "Select the node with min moving average of latencies from the group for every connection",
31 | "Random": "Randomly select a node from the group for every connection"
32 | },
33 | "lanInterface": "The LAN interface to bind. Use it if you want to proxy LAN",
34 | "pleaseEnter": "Please enter your {{fieldName}}",
35 | "pleaseSelect": "Please select your {{fieldName}}",
36 | "sniffingTimeout": "Timeout to waiting for first data sending for sniffing. It is always 0 if dial_mode is ip. Set it higher is useful in high latency LAN network",
37 | "soMarkFromDae": "If not zero, traffic sent from dae will be set SO_MARK. It is useful to avoid traffic loop with iptables/nftables tproxy rules",
38 | "tcpCheckHttpMethod": "The HTTP request method to TCP Check HTTP Method. Use HEAD by default because some server implementations bypass accounting for this kind of traffic",
39 | "tcpCheckUrl": "Host of URL should have both IPV4 and IPV6 if you have Dual Stack in local. First is URL, others are IP addresses if given. Considering traffic consumption, it is recommended to choose a site with Anycast IP and less response",
40 | "tlsImplementation": "TLS implementation. tls is to use GO's crypto/tls. utls is to use uTLS, which can imitate browser's Client Hello",
41 | "tproxyPort": "Transparent Proxy Port to listen on. Valid range is 0 - 65535. It is NOT a HTTP/SOCKS port, and is just used by eBPF program. In normal case, you do not need to use it",
42 | "tproxyPortProtect": "Set it true to protect tproxy port from unsolicited traffic. Set it false to allow users to use self-managed iptables tproxy rules",
43 | "udpCheckDns": "This DNS will be used to check UDP connectivity of nodes. And if dns_upstream below contains tcp, it also be used to check TCP DNS connectivity of nodes. First is URL, others are IP addresses if given. This DNS should have both IPV4 and IPV6 if you have Dual Stack in local",
44 | "utlsImitate": "The Client Hello ID for uTLS to imitate. This takes effect only if tls_implementation is utls",
45 | "wanInterface": "The WAN interface to bind. Use it if you want to proxy localhost"
46 | },
47 | "errors": {
48 | "passwordDontMatch": "密码不匹配"
49 | },
50 | "fields": {
51 | "allowInsecure": "Allow Insecure",
52 | "autoConfigKernelParameter": "Auto Config Kernel Parameter",
53 | "checkInterval": "Check Interval",
54 | "checkTolerance": "Check Tolerance",
55 | "confirmPassword": "Confirm Password",
56 | "currentPassword": "Current Password",
57 | "dialMode": "Dial Mode",
58 | "dialModes": {
59 | "domain": "Dial proxy using the domain from sniffing. This will relieve DNS pollution problem to a great extent if have impure DNS environment. Generally, this mode brings faster proxy response time because proxy will re-resolve the domain in remote, thus get better IP result to connect. This policy does not impact routing. That is to say, domain rewrite will be after traffic split of routing and dae will not re-route it.",
60 | "domain+": "Based on domain mode but do not check the reality of sniffed domain. It is useful for users whose DNS requests do not go through dae but want faster proxy response time. Notice that, if DNS requests do not go through dae, dae cannot split traffic by domain.",
61 | "domain++": "Based on domain+ mode but force to re-route traffic using sniffed domain to partially recover domain based traffic split ability. It doesn't work for direct traffic and consumes more CPU resources.",
62 | "ip": "Dial proxy using the IP from DNS directly. This allows your IPV4, IPV6 to choose the optimal path respectively, and makes the IP version requested by the application meet expectations. For example, if you use curl -4 ip.sb, you will request IPV4 via proxy and get a IPV4 echo. And curl -6 ip.sb will request IPV6. This may solve some weird full-cone problem if your are be your node support that. Sniffing will be disabled in this mode."
63 | },
64 | "disableWaitingNetwork": "Disable Waiting Network",
65 | "lanInterface": "LAN Interface",
66 | "link": "Link",
67 | "logLevel": "Log Level",
68 | "logLevels": {
69 | "debug": "debug",
70 | "error": "error",
71 | "info": "info",
72 | "trace": "trace",
73 | "warn": "warn"
74 | },
75 | "name": "Name",
76 | "newPassword": "New Password",
77 | "password": "Password",
78 | "sniffingTimeout": "Sniffing Timeout",
79 | "soMarkFromDae": "Set SO_MARK For dae",
80 | "tag": "Tag",
81 | "tcpCheckHttpMethod": "TCP Check HTTP Method",
82 | "tcpCheckUrl": "TCP Check URL",
83 | "tlsImplementation": "TLS Implementation",
84 | "tproxyPort": "Transparent Proxy Port",
85 | "tproxyPortProtect": "Transparent Proxy Port Protect",
86 | "udpCheckDns": "UDP Check DNS",
87 | "username": "Username",
88 | "utlsImitate": "uTLS Imitate",
89 | "wanInterface": "WAN Interface"
90 | }
91 | },
92 | "primitives": {
93 | "accountName": "Account Name: {{accountName}}",
94 | "accountSettings": "Account Settings",
95 | "action": "Action",
96 | "address": "Address",
97 | "autoDetect": "Auto Detect",
98 | "chineseSimplified": "Chinese Simplified",
99 | "config": "Config",
100 | "connectingOptions": "Connecting Options",
101 | "create": "Create {{resourceName}}",
102 | "default": "Default",
103 | "dns": "DNS",
104 | "endpointURL": "Endpoint URL",
105 | "english": "English",
106 | "general": "General",
107 | "group": "Group",
108 | "interfaceAndKernelOptions": "Interface and Kernel Options",
109 | "millisecond": "ms",
110 | "name": "Name",
111 | "network": "Network",
112 | "node": "Node",
113 | "nodeConnectivityCheck": "Node Connectivity Check",
114 | "policy": "Policy",
115 | "profile": "Profile",
116 | "protocol": "Protocol",
117 | "remove": "Remove {{resourceName}}",
118 | "routing": "Routing",
119 | "rule": "Rule",
120 | "second": "s",
121 | "settings": "Settings",
122 | "softwareOptions": "Software Options",
123 | "subscription": "Subscription",
124 | "tag": "Tag",
125 | "updatedAt": "Updated At",
126 | "username": "Username: {{username}}"
127 | }
128 | }
129 |
--------------------------------------------------------------------------------
/src/apis/query.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from '@tanstack/react-query'
2 | import { graphql } from '~/apis/gql/gql'
3 | import { useGraphqlClient } from '~/contexts'
4 |
5 | export const useGetJSONStorageRequest = >(paths: T) => {
6 | const gqlClient = useGraphqlClient()
7 |
8 | return useQuery({
9 | queryKey: ['jsonStorage', paths],
10 | queryFn: async () => {
11 | const { jsonStorage } = await gqlClient.request(
12 | graphql(`
13 | query JsonStorage($paths: [String!]) {
14 | jsonStorage(paths: $paths)
15 | }
16 | `),
17 | { paths: paths as unknown as string[] }
18 | )
19 |
20 | return jsonStorage.reduce(
21 | (prev, cur, index) => ({ ...prev, [paths[index]]: cur }),
22 | {} as { [key in T[number]]: (typeof jsonStorage)[number] }
23 | )
24 | }
25 | })
26 | }
27 |
28 | export const generalQueryKey = ['general']
29 | export const useGeneralQuery = () => {
30 | const gqlClient = useGraphqlClient()
31 |
32 | return useQuery({
33 | queryKey: generalQueryKey,
34 | queryFn: () =>
35 | gqlClient.request(
36 | graphql(`
37 | query General($interfacesUp: Boolean) {
38 | general {
39 | dae {
40 | running
41 | modified
42 | version
43 | }
44 |
45 | interfaces(up: $interfacesUp) {
46 | name
47 | ifindex
48 | ip
49 | flag {
50 | default {
51 | ipVersion
52 | gateway
53 | source
54 | }
55 | }
56 | }
57 | }
58 | }
59 | `),
60 | { interfacesUp: true }
61 | )
62 | })
63 | }
64 |
65 | export const nodesQueryKey = ['nodes']
66 |
67 | export const useNodesQuery = () => {
68 | const gqlClient = useGraphqlClient()
69 |
70 | return useQuery({
71 | queryKey: nodesQueryKey,
72 | queryFn: () =>
73 | gqlClient.request(
74 | graphql(`
75 | query Nodes {
76 | nodes {
77 | edges {
78 | id
79 | name
80 | link
81 | address
82 | protocol
83 | tag
84 | subscriptionID
85 | }
86 | }
87 | }
88 | `)
89 | )
90 | })
91 | }
92 |
93 | export const subscriptionsQueryKey = ['subscriptions']
94 |
95 | export const useSubscriptionsQuery = () => {
96 | const gqlClient = useGraphqlClient()
97 |
98 | return useQuery({
99 | queryKey: subscriptionsQueryKey,
100 | queryFn: () =>
101 | gqlClient.request(
102 | graphql(`
103 | query Subscriptions {
104 | subscriptions {
105 | id
106 | tag
107 | status
108 | link
109 | info
110 | updatedAt
111 | nodes {
112 | edges {
113 | id
114 | name
115 | address
116 | protocol
117 | link
118 | }
119 | }
120 | }
121 | }
122 | `)
123 | )
124 | })
125 | }
126 |
127 | export const configsQueryKey = ['configs']
128 |
129 | export const useConfigsQuery = () => {
130 | const gqlClient = useGraphqlClient()
131 |
132 | return useQuery({
133 | queryKey: configsQueryKey,
134 | queryFn: () =>
135 | gqlClient.request(
136 | graphql(`
137 | query Configs {
138 | configs {
139 | id
140 | name
141 | selected
142 | global {
143 | logLevel
144 | tproxyPort
145 | allowInsecure
146 | checkInterval
147 | checkTolerance
148 | lanInterface
149 | wanInterface
150 | udpCheckDns
151 | tcpCheckUrl
152 | dialMode
153 | tcpCheckHttpMethod
154 | disableWaitingNetwork
155 | autoConfigKernelParameter
156 | sniffingTimeout
157 | tlsImplementation
158 | utlsImitate
159 | tproxyPortProtect
160 | soMarkFromDae
161 | }
162 | }
163 | }
164 | `)
165 | )
166 | })
167 | }
168 |
169 | export const groupsQueryKey = ['groups']
170 |
171 | export const useGroupsQuery = () => {
172 | const gqlClient = useGraphqlClient()
173 |
174 | return useQuery({
175 | queryKey: groupsQueryKey,
176 | queryFn: () =>
177 | gqlClient.request(
178 | graphql(`
179 | query Groups {
180 | groups {
181 | id
182 | name
183 | nodes {
184 | id
185 | link
186 | name
187 | address
188 | protocol
189 | tag
190 | subscriptionID
191 | }
192 | subscriptions {
193 | id
194 | updatedAt
195 | tag
196 | link
197 | status
198 | info
199 |
200 | nodes {
201 | edges {
202 | id
203 | link
204 | name
205 | address
206 | protocol
207 | tag
208 | subscriptionID
209 | }
210 | }
211 | }
212 | policy
213 | policyParams {
214 | key
215 | val
216 | }
217 | }
218 | }
219 | `)
220 | )
221 | })
222 | }
223 |
224 | export const routingsQueryKey = ['rules']
225 |
226 | export const useRoutingsQuery = () => {
227 | const gqlClient = useGraphqlClient()
228 |
229 | return useQuery({
230 | queryKey: routingsQueryKey,
231 | queryFn: () =>
232 | gqlClient.request(
233 | graphql(`
234 | query Routings {
235 | routings {
236 | id
237 | name
238 | selected
239 | routing {
240 | string
241 | }
242 | }
243 | }
244 | `)
245 | )
246 | })
247 | }
248 |
249 | export const dnssQueryKey = ['dnss']
250 |
251 | export const useDNSsQuery = () => {
252 | const gqlClient = useGraphqlClient()
253 |
254 | return useQuery({
255 | queryKey: dnssQueryKey,
256 | queryFn: () =>
257 | gqlClient.request(
258 | graphql(`
259 | query DNSs {
260 | dnss {
261 | id
262 | name
263 | selected
264 | dns {
265 | string
266 |
267 | upstream {
268 | key
269 | val
270 | }
271 |
272 | routing {
273 | request {
274 | string
275 | }
276 |
277 | response {
278 | string
279 | }
280 | }
281 | }
282 | }
283 | }
284 | `)
285 | )
286 | })
287 | }
288 |
289 | export const userQueryKey = ['user']
290 |
291 | export const useUserQuery = () => {
292 | const gqlClient = useGraphqlClient()
293 |
294 | return useQuery({
295 | queryKey: userQueryKey,
296 | queryFn: () =>
297 | gqlClient.request(
298 | graphql(`
299 | query User {
300 | user {
301 | username
302 | name
303 | avatar
304 | }
305 | }
306 | `)
307 | )
308 | })
309 | }
310 |
--------------------------------------------------------------------------------
/src/app/(protected)/network/NodeSection.tsx:
--------------------------------------------------------------------------------
1 | import { zodResolver } from '@hookform/resolvers/zod'
2 | import {
3 | Input,
4 | ModalBody,
5 | ModalContent,
6 | ModalHeader,
7 | Snippet,
8 | Table,
9 | TableBody,
10 | TableCell,
11 | TableColumn,
12 | TableHeader,
13 | TableRow,
14 | getKeyValue,
15 | useDisclosure
16 | } from '@nextui-org/react'
17 | import { IconPlus, IconQrcode, IconTrash, IconUpload } from '@tabler/icons-react'
18 | import { QRCodeSVG } from 'qrcode.react'
19 | import { FC, Fragment, Key, useCallback, useMemo } from 'react'
20 | import { Controller, FormProvider, useFieldArray, useForm } from 'react-hook-form'
21 | import { useTranslation } from 'react-i18next'
22 | import { z } from 'zod'
23 | import { useImportNodesMutation, useRemoveNodesMutation } from '~/apis/mutation'
24 | import { Node } from '~/app/(protected)/network/typings'
25 | import { Button } from '~/components/Button'
26 | import { Modal, ModalConfirmFormFooter, ModalSubmitFormFooter } from '~/components/Modal'
27 | import { nodeFormDefault, nodeFormSchema } from '~/schemas/node'
28 |
29 | const CheckNodeQRCodeButton: FC<{ name: string; link: string }> = ({ name, link }) => {
30 | const {
31 | isOpen: isCheckNodeQRCodeOpen,
32 | onOpenChange: onCheckNodeQRCodeOpenChange,
33 | onOpen: onCheckNodeQRCodeOpen
34 | } = useDisclosure()
35 |
36 | return (
37 |
38 |
41 |
42 |
43 |
44 | {name}
45 |
46 |
47 |
48 |
49 | {link}
50 |
51 |
52 |
53 |
54 |
55 | )
56 | }
57 |
58 | const RemoveNodeButton: FC<{ id: string; name: string }> = ({ id, name }) => {
59 | const { t } = useTranslation()
60 |
61 | const {
62 | isOpen: isRemoveOpen,
63 | onOpen: onRemoveOpen,
64 | onClose: onRemoveClose,
65 | onOpenChange: onRemoveOpenChange
66 | } = useDisclosure()
67 |
68 | const removeNodesMutation = useRemoveNodesMutation()
69 |
70 | return (
71 |
72 |
75 |
76 |
77 |
78 | {t('primitives.remove', { resourceName: t('primitives.node') })}
79 | {name}
80 |
81 | {
85 | await removeNodesMutation.mutateAsync({ nodeIDs: [id] })
86 |
87 | onRemoveClose()
88 | }}
89 | />
90 |
91 |
92 |
93 | )
94 | }
95 |
96 | const NodeTable: FC<{
97 | nodes: Node[]
98 | isLoading?: boolean
99 | }> = ({ nodes, isLoading }) => {
100 | const { t } = useTranslation()
101 |
102 | const nodesTableColumns = useMemo(
103 | () => [
104 | { key: 'name', name: t('primitives.name') },
105 | { key: 'protocol', name: t('primitives.protocol') },
106 | { key: 'address', name: t('primitives.address') },
107 | { key: 'action', name: t('primitives.action') }
108 | ],
109 | [t]
110 | )
111 |
112 | const renderCell = useCallback((item: Node, columnKey: Key) => {
113 | switch (columnKey) {
114 | case 'name':
115 | return item.tag || item.name
116 |
117 | case 'action':
118 | return (
119 |
120 |
121 |
122 |
123 |
124 | )
125 |
126 | default:
127 | return getKeyValue(item, columnKey)
128 | }
129 | }, [])
130 |
131 | return (
132 |
133 |
134 | {(column) => {column.name}}
135 |
136 |
137 |
138 | {(item) => (
139 | {(columnKey) => {renderCell(item, columnKey)}}
140 | )}
141 |
142 |
143 | )
144 | }
145 |
146 | const ImportNodeInputList: FC<{ name: string }> = ({ name }) => {
147 | const { t } = useTranslation()
148 | const { fields, append, remove } = useFieldArray({ name })
149 |
150 | return (
151 |
193 | )
194 | }
195 |
196 | export const NodeSection: FC<{ nodes: Node[]; isLoading?: boolean }> = ({ nodes, isLoading }) => {
197 | const { t } = useTranslation()
198 | const {
199 | isOpen: isImportNodeOpen,
200 | onOpen: onImportNodeOpen,
201 | onClose: onImportNodeClose,
202 | onOpenChange: onImportNodeOpenChange
203 | } = useDisclosure()
204 |
205 | const importNodeForm = useForm>({
206 | shouldFocusError: true,
207 | resolver: zodResolver(nodeFormSchema),
208 | defaultValues: nodeFormDefault
209 | })
210 |
211 | const importNodesMutation = useImportNodesMutation()
212 |
213 | return (
214 |
215 |
216 |
{t('primitives.node')}
217 |
220 |
221 |
222 |
223 |
240 |
241 |
242 |
243 |
244 |
245 |
246 | )
247 | }
248 |
--------------------------------------------------------------------------------
/src/app/(protected)/network/SubscriptionSection.tsx:
--------------------------------------------------------------------------------
1 | import { zodResolver } from '@hookform/resolvers/zod'
2 | import {
3 | Accordion,
4 | AccordionItem,
5 | getKeyValue,
6 | Input,
7 | ModalBody,
8 | ModalContent,
9 | ModalHeader,
10 | Table,
11 | TableBody,
12 | TableCell,
13 | TableColumn,
14 | TableHeader,
15 | TableRow,
16 | useDisclosure
17 | } from '@nextui-org/react'
18 | import { IconPlus, IconRefresh, IconTrash, IconUpload } from '@tabler/icons-react'
19 | import dayjs from 'dayjs'
20 | import { FC, Fragment, useEffect, useMemo } from 'react'
21 | import { Controller, FormProvider, useFieldArray, useForm } from 'react-hook-form'
22 | import { useTranslation } from 'react-i18next'
23 | import { z } from 'zod'
24 | import {
25 | useImportSubscriptionsMutation,
26 | useRemoveSubscriptionsMutation,
27 | useUpdateSubscriptionsMutation
28 | } from '~/apis/mutation'
29 | import { Button } from '~/components/Button'
30 | import { Modal, ModalConfirmFormFooter, ModalSubmitFormFooter } from '~/components/Modal'
31 | import { subscriptionFormDefault, subscriptionFormSchema } from '~/schemas/subscription'
32 | import { Node, Subscription } from './typings'
33 |
34 | const SubscriptionNodeTable: FC<{
35 | nodes: Node[]
36 | isLoading?: boolean
37 | }> = ({ nodes, isLoading }) => {
38 | const { t } = useTranslation()
39 |
40 | const nodesTableColumns = useMemo(
41 | () => [
42 | { key: 'name', name: t('primitives.name') },
43 | { key: 'protocol', name: t('primitives.protocol') },
44 | { key: 'address', name: t('primitives.address') }
45 | ],
46 | [t]
47 | )
48 |
49 | return (
50 |
51 |
52 | {(column) => {column.name}}
53 |
54 |
55 |
56 | {(item) => (
57 | {(columnKey) => {getKeyValue(item, columnKey)}}
58 | )}
59 |
60 |
61 | )
62 | }
63 |
64 | const ImportSubscriptionInputList: FC<{ name: string }> = ({ name }) => {
65 | const { t } = useTranslation()
66 | const { fields, append, remove } = useFieldArray({ name })
67 |
68 | return (
69 |
111 | )
112 | }
113 |
114 | export const SubscriptionSection: FC<{ subscriptions: Subscription[] }> = ({ subscriptions }) => {
115 | const { t } = useTranslation()
116 |
117 | const {
118 | isOpen: isImportSubscriptionOpen,
119 | onOpen: onImportSubscriptionOpen,
120 | onClose: onImportSubscriptionClose,
121 | onOpenChange: onImportSubscriptionOpenChange
122 | } = useDisclosure()
123 |
124 | const importSubscriptionForm = useForm>({
125 | shouldFocusError: true,
126 | resolver: zodResolver(subscriptionFormSchema),
127 | defaultValues: subscriptionFormDefault
128 | })
129 |
130 | const {
131 | isOpen: isRemoveSubscriptionOpen,
132 | onOpen: onRemoveSubscriptionOpen,
133 | onClose: onRemoveSubscriptionClose,
134 | onOpenChange: onRemoveSubscriptionOpenChange
135 | } = useDisclosure()
136 |
137 | const importSubscriptionsMutation = useImportSubscriptionsMutation()
138 | const removeSubscriptionsMutation = useRemoveSubscriptionsMutation()
139 | const updateSubscriptionsMutation = useUpdateSubscriptionsMutation()
140 |
141 | useEffect(() => {
142 | const timer = setTimeout(() => {
143 | if (!isImportSubscriptionOpen) importSubscriptionForm.reset()
144 | }, 150)
145 |
146 | return () => timer && clearTimeout(timer)
147 | }, [importSubscriptionForm, isImportSubscriptionOpen])
148 |
149 | return (
150 |
151 |
152 |
{t('primitives.subscription')}
153 |
154 |
157 |
158 |
159 |
160 |
180 |
181 |
182 |
183 |
184 |
185 | {subscriptions.map((subscription) => (
186 |
192 |
201 |
202 |
203 |
206 |
207 |
208 |
209 |
210 | {t('primitives.remove', { resourceName: t('primitives.subscription') })}
211 |
212 | {subscription.tag}
213 |
214 | {
218 | await removeSubscriptionsMutation.mutateAsync({ subscriptionIDs: [subscription.id] })
219 |
220 | onRemoveSubscriptionClose()
221 | }}
222 | />
223 |
224 |
225 |
226 |
227 | }
228 | >
229 |
230 |
231 | ))}
232 |
233 |
234 | )
235 | }
236 |
--------------------------------------------------------------------------------
/src/app/(protected)/rule/DNSSection.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { zodResolver } from '@hookform/resolvers/zod'
4 | import { Input, ModalBody, ModalContent, ModalHeader, useDisclosure } from '@nextui-org/react'
5 | import { IconCode, IconEdit, IconPlus, IconTrash } from '@tabler/icons-react'
6 | import { FC, Fragment, useEffect } from 'react'
7 | import { Controller, SubmitHandler, UseFormReturn, useForm } from 'react-hook-form'
8 | import { useTranslation } from 'react-i18next'
9 | import { z } from 'zod'
10 | import {
11 | useCreateDNSMutation,
12 | useRemoveDNSMutation,
13 | useRenameDNSMutation,
14 | useSelectDNSMutation,
15 | useUpdateDNSMutation
16 | } from '~/apis/mutation'
17 | import { useDNSsQuery, useGetJSONStorageRequest } from '~/apis/query'
18 | import { Button } from '~/components/Button'
19 | import { CodeBlock } from '~/components/CodeBlock'
20 | import { Editor } from '~/components/Editor'
21 | import { Modal, ModalConfirmFormFooter, ModalSubmitFormFooter } from '~/components/Modal'
22 | import { ResourceRadio, ResourceRadioGroup } from '~/components/ResourceRadioGroup'
23 | import { DNSFormDefault, DNSFormSchema } from '~/schemas/dns'
24 |
25 | type CreateOrEditModalContentProps = {
26 | isOpen: boolean
27 | onOpenChange: () => void
28 | form: UseFormReturn>
29 | onSubmit: SubmitHandler>
30 | }
31 |
32 | type CreateModalContentProps = {
33 | type: 'create'
34 | }
35 |
36 | type EditModalContentProps = {
37 | type: 'edit'
38 | name: string
39 | id: string
40 | }
41 |
42 | const CreateOrEditModal: FC = ({
43 | isOpen,
44 | onOpenChange,
45 | ...createOrEditProps
46 | }) => {
47 | const { t } = useTranslation()
48 | const { type, form, onSubmit } = createOrEditProps
49 | const {
50 | register,
51 | reset,
52 | control,
53 | formState: { errors, dirtyFields, isSubmitting }
54 | } = form
55 | const dirty = Object.values(dirtyFields).some((dirty) => dirty)
56 |
57 | useEffect(() => {
58 | const timer = setTimeout(() => {
59 | if (!isOpen) reset()
60 | }, 150)
61 |
62 | return () => timer && clearTimeout(timer)
63 | }, [reset, isOpen])
64 |
65 | return (
66 |
67 |
68 |
100 |
101 |
102 | )
103 | }
104 |
105 | type Details = {
106 | id: string
107 | name: string
108 | selected: boolean
109 |
110 | dns: {
111 | string: string
112 | }
113 | }
114 |
115 | const DetailsModal: FC<{
116 | details: Details
117 | isOpen: boolean
118 | onOpenChange: () => void
119 | }> = ({ details, isOpen, onOpenChange }) => (
120 |
121 |
122 | {details.id}
123 |
124 |
125 | {details.dns.string}
126 |
127 |
128 |
129 | )
130 |
131 | const DetailsRadio: FC<{
132 | details: Details
133 | isDefault?: boolean
134 | }> = ({ details, isDefault }) => {
135 | const { t } = useTranslation()
136 | const { isOpen: isDetailsOpen, onOpen: onDetailsOpen, onOpenChange: onDetailsOpenChange } = useDisclosure()
137 | const {
138 | isOpen: isEditOpen,
139 | onOpen: onEditOpen,
140 | onClose: onEditClose,
141 | onOpenChange: onEditOpenChange
142 | } = useDisclosure()
143 | const {
144 | isOpen: isRemoveOpen,
145 | onOpen: onRemoveOpen,
146 | onClose: onRemoveClose,
147 | onOpenChange: onRemoveOpenChange
148 | } = useDisclosure()
149 | const editForm = useForm>({
150 | shouldFocusError: true,
151 | resolver: zodResolver(DNSFormSchema),
152 | defaultValues: DNSFormDefault
153 | })
154 |
155 | const renameMutation = useRenameDNSMutation()
156 | const updateMutation = useUpdateDNSMutation()
157 | const removeMutation = useRemoveDNSMutation()
158 |
159 | const onEditPress = (name: string) => {
160 | editForm.reset({
161 | name,
162 | text: details.dns.string
163 | })
164 | onEditOpen()
165 | }
166 |
167 | const onEditSubmit: (id: string, name: string) => CreateOrEditModalContentProps['onSubmit'] =
168 | (id, name) => async (values) => {
169 | const { text } = values
170 |
171 | await updateMutation.mutateAsync({
172 | id,
173 | dns: text
174 | })
175 |
176 | if (values.name !== name) {
177 | await renameMutation.mutateAsync({
178 | id,
179 | name: values.name
180 | })
181 | }
182 |
183 | onEditClose()
184 | }
185 |
186 | return (
187 |
192 |
195 |
196 |
197 |
198 |
199 |
202 |
203 |
212 |
213 | {!isDefault && (
214 |
215 |
218 |
219 |
220 |
221 | {t('primitives.remove', { resourceName: t('primitives.dns') })}
222 | {details.name}
223 |
224 | {
228 | await removeMutation.mutateAsync({ id: details.id })
229 | onRemoveClose()
230 | }}
231 | />
232 |
233 |
234 |
235 | )}
236 |
237 |
238 | }
239 | >
240 | {details.name}
241 |
242 | )
243 | }
244 |
245 | export const DNSSection = () => {
246 | const { t } = useTranslation()
247 | const createForm = useForm>({
248 | shouldFocusError: true,
249 | resolver: zodResolver(DNSFormSchema),
250 | defaultValues: DNSFormDefault
251 | })
252 | const defaultDNSIDQuery = useGetJSONStorageRequest(['defaultDNSID'] as const)
253 | const listQuery = useDNSsQuery()
254 | const {
255 | isOpen: isCreateOpen,
256 | onOpenChange: onCreateOpenChange,
257 | onOpen: onCreateOpen,
258 | onClose: onCreateClose
259 | } = useDisclosure()
260 | const isDefault = (id: string) => id === defaultDNSIDQuery.data?.defaultDNSID
261 |
262 | const createMutation = useCreateDNSMutation()
263 | const selectMutation = useSelectDNSMutation()
264 |
265 | const onCreateSubmit: CreateOrEditModalContentProps['onSubmit'] = async ({ name, text }) => {
266 | await createMutation.mutateAsync({ name, dns: text })
267 |
268 | onCreateClose()
269 | }
270 |
271 | return (
272 | selected)?.id || ''}
274 | onValueChange={async (id) => {
275 | await selectMutation.mutateAsync({ id })
276 | }}
277 | label={
278 |
279 |
{t('primitives.dns')}
280 |
281 |
282 |
285 |
286 |
293 |
294 |
295 | }
296 | >
297 | {listQuery.data?.dnss.map((details) => (
298 |
299 | ))}
300 |
301 | )
302 | }
303 |
--------------------------------------------------------------------------------
/src/app/(protected)/rule/RoutingSection.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { zodResolver } from '@hookform/resolvers/zod'
4 | import { Input, ModalBody, ModalContent, ModalHeader, useDisclosure } from '@nextui-org/react'
5 | import { IconCode, IconEdit, IconPlus, IconTrash } from '@tabler/icons-react'
6 | import { FC, Fragment, useEffect } from 'react'
7 | import { Controller, SubmitHandler, UseFormReturn, useForm } from 'react-hook-form'
8 | import { useTranslation } from 'react-i18next'
9 | import { z } from 'zod'
10 | import {
11 | useCreateRoutingMutation,
12 | useRemoveRoutingMutation,
13 | useRenameRoutingMutation,
14 | useSelectRoutingMutation,
15 | useUpdateRoutingMutation
16 | } from '~/apis/mutation'
17 | import { useGetJSONStorageRequest, useRoutingsQuery } from '~/apis/query'
18 | import { Button } from '~/components/Button'
19 | import { CodeBlock } from '~/components/CodeBlock'
20 | import { Editor } from '~/components/Editor'
21 | import { Modal, ModalConfirmFormFooter, ModalSubmitFormFooter } from '~/components/Modal'
22 | import { ResourceRadio, ResourceRadioGroup } from '~/components/ResourceRadioGroup'
23 | import { routingFormDefault, routingFormSchema } from '~/schemas/routing'
24 |
25 | type CreateOrEditModalContentProps = {
26 | isOpen: boolean
27 | onOpenChange: () => void
28 | form: UseFormReturn>
29 | onSubmit: SubmitHandler>
30 | }
31 |
32 | type CreateModalContentProps = {
33 | type: 'create'
34 | }
35 |
36 | type EditModalContentProps = {
37 | type: 'edit'
38 | name: string
39 | id: string
40 | }
41 |
42 | const CreateOrEditModal: FC = ({
43 | isOpen,
44 | onOpenChange,
45 | ...createOrEditProps
46 | }) => {
47 | const { t } = useTranslation()
48 | const { type, form, onSubmit } = createOrEditProps
49 | const {
50 | register,
51 | reset,
52 | control,
53 | formState: { errors, dirtyFields, isSubmitting }
54 | } = form
55 | const dirty = Object.values(dirtyFields).some((dirty) => dirty)
56 |
57 | useEffect(() => {
58 | const timer = setTimeout(() => {
59 | if (!isOpen) reset()
60 | }, 150)
61 |
62 | return () => timer && clearTimeout(timer)
63 | }, [reset, isOpen])
64 |
65 | return (
66 |
67 |
68 |
100 |
101 |
102 | )
103 | }
104 |
105 | type Details = {
106 | id: string
107 | name: string
108 | selected: boolean
109 |
110 | routing: {
111 | string: string
112 | }
113 | }
114 |
115 | const DetailsModal: FC<{
116 | details: Details
117 | isOpen: boolean
118 | onOpenChange: () => void
119 | }> = ({ details, isOpen, onOpenChange }) => (
120 |
121 |
122 | {details.id}
123 |
124 |
125 | {details.routing.string}
126 |
127 |
128 |
129 | )
130 |
131 | const DetailsRadio: FC<{
132 | details: Details
133 | isDefault?: boolean
134 | }> = ({ details, isDefault }) => {
135 | const { t } = useTranslation()
136 | const { isOpen: isDetailsOpen, onOpen: onDetailsOpen, onOpenChange: onDetailsOpenChange } = useDisclosure()
137 | const {
138 | isOpen: isEditOpen,
139 | onOpen: onEditOpen,
140 | onClose: onEditClose,
141 | onOpenChange: onEditOpenChange
142 | } = useDisclosure()
143 | const {
144 | isOpen: isRemoveOpen,
145 | onOpen: onRemoveOpen,
146 | onClose: onRemoveClose,
147 | onOpenChange: onRemoveOpenChange
148 | } = useDisclosure()
149 | const editForm = useForm>({
150 | shouldFocusError: true,
151 | resolver: zodResolver(routingFormSchema),
152 | defaultValues: routingFormDefault
153 | })
154 | const renameMutation = useRenameRoutingMutation()
155 | const updateMutation = useUpdateRoutingMutation()
156 | const removeMutation = useRemoveRoutingMutation()
157 |
158 | const onEditPress = (name: string) => {
159 | editForm.reset({
160 | name,
161 | text: details.routing.string
162 | })
163 | onEditOpen()
164 | }
165 |
166 | const onEditSubmit: (id: string, name: string) => CreateOrEditModalContentProps['onSubmit'] =
167 | (id, name) => async (values) => {
168 | const { text } = values
169 |
170 | await updateMutation.mutateAsync({
171 | id,
172 | routing: text
173 | })
174 |
175 | if (values.name !== name) {
176 | await renameMutation.mutateAsync({
177 | id,
178 | name: values.name
179 | })
180 | }
181 |
182 | onEditClose()
183 | }
184 |
185 | return (
186 |
191 |
194 |
195 |
196 |
197 |
198 |
201 |
202 |
211 |
212 | {!isDefault && (
213 |
214 |
217 |
218 |
219 |
220 | {t('primitives.remove', { resourceName: t('primitives.routing') })}
221 | {details.name}
222 |
223 | {
227 | await removeMutation.mutateAsync({ id: details.id })
228 | onRemoveClose()
229 | }}
230 | />
231 |
232 |
233 |
234 | )}
235 |
236 |
237 | }
238 | >
239 | {details.name}
240 |
241 | )
242 | }
243 |
244 | export const RoutingSection = () => {
245 | const { t } = useTranslation()
246 | const createForm = useForm>({
247 | shouldFocusError: true,
248 | resolver: zodResolver(routingFormSchema),
249 | defaultValues: routingFormDefault
250 | })
251 | const defaultRoutingIDQuery = useGetJSONStorageRequest(['defaultRoutingID'] as const)
252 | const listQuery = useRoutingsQuery()
253 | const {
254 | isOpen: isCreateOpen,
255 | onOpenChange: onCreateOpenChange,
256 | onOpen: onCreateOpen,
257 | onClose: onCreateClose
258 | } = useDisclosure()
259 | const isDefault = (id: string) => id === defaultRoutingIDQuery.data?.defaultRoutingID
260 |
261 | const createMutation = useCreateRoutingMutation()
262 | const selectMutation = useSelectRoutingMutation()
263 |
264 | const onCreateSubmit: CreateOrEditModalContentProps['onSubmit'] = async ({ name, text }) => {
265 | await createMutation.mutateAsync({ name, routing: text })
266 |
267 | onCreateClose()
268 | }
269 |
270 | return (
271 | selected)?.id || ''}
273 | onValueChange={async (id) => {
274 | await selectMutation.mutateAsync({ id })
275 | }}
276 | label={
277 |
278 |
{t('primitives.routing')}
279 |
280 |
281 |
284 |
285 |
292 |
293 |
294 | }
295 | >
296 | {listQuery.data?.routings.map((details) => (
297 |
298 | ))}
299 |
300 | )
301 | }
302 |
--------------------------------------------------------------------------------
/src/components/Header.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { zodResolver } from '@hookform/resolvers/zod'
4 | import {
5 | Avatar,
6 | ButtonGroup,
7 | Dropdown,
8 | DropdownItem,
9 | DropdownMenu,
10 | DropdownSection,
11 | DropdownTrigger,
12 | Input,
13 | Link,
14 | ModalBody,
15 | ModalContent,
16 | ModalHeader,
17 | Navbar,
18 | NavbarBrand,
19 | NavbarContent,
20 | NavbarItem,
21 | NavbarMenu,
22 | NavbarMenuItem,
23 | NavbarMenuToggle,
24 | Spinner,
25 | useDisclosure
26 | } from '@nextui-org/react'
27 | import { IconNetwork, IconNetworkOff, IconReload } from '@tabler/icons-react'
28 | import i18n from 'i18next'
29 | import ky from 'ky'
30 | import { useTheme } from 'next-themes'
31 | import NextLink from 'next/link'
32 | import { usePathname, useRouter } from 'next/navigation'
33 | import { FC, useEffect, useState } from 'react'
34 | import { Controller, FormProvider, useForm } from 'react-hook-form'
35 | import { useTranslation } from 'react-i18next'
36 | import { z } from 'zod'
37 | import { useRunMutation, useUpdateAvatarMutation, useUpdateNameMutation } from '~/apis/mutation'
38 | import { useGeneralQuery, useUserQuery } from '~/apis/query'
39 | import { Button } from '~/components/Button'
40 | import { LogoText } from '~/components/LogoText'
41 | import { Modal, ModalConfirmFormFooter, ModalSubmitFormFooter } from '~/components/Modal'
42 | import { updatePasswordFormDefault, useUpdatePasswordSchemaWithRefine } from '~/schemas/account'
43 |
44 | const AvatarUploader: FC<{ name: string }> = ({ name }) => {
45 | const [uploading, setUploading] = useState(false)
46 |
47 | return (
48 | (
51 |
52 |
53 | {uploading ?
:
}
54 |
55 |
56 |
{
63 | setUploading(true)
64 |
65 | const file = e.target.files?.item(0)
66 |
67 | if (!file) return
68 |
69 | const formData = new FormData()
70 | formData.append('avatar', file)
71 |
72 | try {
73 | const { url } = await ky.post('/api/avatar', { body: formData }).json<{ url: string }>()
74 |
75 | field.onChange(url)
76 | } finally {
77 | setUploading(false)
78 | }
79 | }}
80 | />
81 |
82 | )}
83 | />
84 | )
85 | }
86 |
87 | export const Header: FC = () => {
88 | const { t } = useTranslation()
89 | const { theme: curTheme, setTheme } = useTheme()
90 | const pathname = usePathname()
91 | const router = useRouter()
92 |
93 | const userQuery = useUserQuery()
94 | const generalQuery = useGeneralQuery()
95 | const runMutation = useRunMutation()
96 |
97 | const [isMenuOpen, setIsMenuOpen] = useState(false)
98 |
99 | const {
100 | isOpen: isUpdateProfileOpen,
101 | onOpen: onUpdateProfileOpen,
102 | onClose: onUpdateProfileClose,
103 | onOpenChange: onUpdateProfileOpenChange
104 | } = useDisclosure()
105 |
106 | const updateProfileSchema = z.object({
107 | name: z.string().min(4).max(20),
108 | avatar: z.string().min(1)
109 | })
110 |
111 | const updateProfileForm = useForm>({
112 | resolver: zodResolver(updateProfileSchema),
113 | defaultValues: { name: '', avatar: '' }
114 | })
115 |
116 | const updateProfileFormDirty = Object.values(updateProfileForm.formState.dirtyFields).some((dirty) => dirty)
117 |
118 | const updateNameMutation = useUpdateNameMutation()
119 | const updateAvatarMutation = useUpdateAvatarMutation()
120 |
121 | const {
122 | isOpen: isUpdatePasswordOpen,
123 | onOpen: onUpdatePasswordOpen,
124 | onClose: onUpdatePasswordClose,
125 | onOpenChange: onUpdatePasswordOpenChange
126 | } = useDisclosure()
127 |
128 | const updatePasswordSchemaWithRefine = useUpdatePasswordSchemaWithRefine()()
129 |
130 | const updatePasswordForm = useForm>({
131 | resolver: zodResolver(updatePasswordSchemaWithRefine),
132 | defaultValues: updatePasswordFormDefault
133 | })
134 |
135 | const navigationMenus = [
136 | { name: t('primitives.network'), route: '/network' },
137 | { name: t('primitives.rule'), route: '/rule' }
138 | ]
139 |
140 | useEffect(() => {
141 | const timer = setTimeout(() => {
142 | if (!isUpdatePasswordOpen) updatePasswordForm.reset()
143 | }, 150)
144 |
145 | return () => timer && clearTimeout(timer)
146 | }, [isUpdatePasswordOpen, updatePasswordForm])
147 |
148 | return (
149 |
150 |
151 |
152 |
153 |
154 | setIsMenuOpen(false)}>
155 |
156 |
157 |
158 |
159 |
160 |
161 | {navigationMenus.map((menu, index) => (
162 |
163 |
164 | {menu.name}
165 |
166 |
167 | ))}
168 |
169 |
170 |
171 | {generalQuery.data?.general.dae.modified ? (
172 |
180 | ) : (
181 |
189 | )}
190 |
191 |
192 |
193 |
203 |
204 |
205 | {
209 | if (key === 'profile') {
210 | updateProfileForm.reset({
211 | name: userQuery.data?.user.name || '',
212 | avatar: userQuery.data?.user.avatar || ''
213 | })
214 | onUpdateProfileOpen()
215 | }
216 | if (key === 'update-password') onUpdatePasswordOpen()
217 | }}
218 | >
219 |
220 |
221 | {t('primitives.username', { username: userQuery.data?.user.username })}
222 |
223 |
224 | {t('primitives.accountName', { accountName: userQuery.data?.user.name })}
225 |
226 |
227 |
228 |
229 | {t('actions.update', {
230 | resourceName: t('form.fields.password')
231 | })}
232 |
233 |
234 |
235 |
236 |
237 |
238 | {[
239 | ['system', t('actions.systemMode')],
240 | ['dark', t('actions.darkMode')],
241 | ['light', t('actions.lightMode')]
242 | ].map(([theme, title]) => (
243 |
250 | ))}
251 |
252 |
253 |
254 |
255 |
256 |
257 |
258 | {[
259 | ['en-US', t('primitives.english')],
260 | ['zh-Hans', t('primitives.chineseSimplified')]
261 | ].map(([language, title]) => (
262 |
269 | ))}
270 |
271 |
272 |
273 |
274 | {
278 | await ky.post('/api/logout')
279 |
280 | router.replace('/login')
281 | }}
282 | >
283 | {t('actions.logout')}
284 |
285 |
286 |
287 |
288 |
289 |
290 | {navigationMenus.map((menu, index) => (
291 |
292 | setIsMenuOpen(false)}>
293 | {menu.name}
294 |
295 |
296 | ))}
297 |
298 |
299 |
300 |
301 |
340 |
341 |
342 |
343 |
344 |
392 |
393 |
394 | )
395 | }
396 | Header.displayName = 'Header'
397 |
--------------------------------------------------------------------------------
/src/app/(protected)/network/GroupSection.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { zodResolver } from '@hookform/resolvers/zod'
4 | import { Divider } from '@nextui-org/divider'
5 | import {
6 | Accordion,
7 | AccordionItem,
8 | Card,
9 | CardBody,
10 | CardHeader,
11 | Chip,
12 | cn,
13 | Dropdown,
14 | DropdownItem,
15 | DropdownMenu,
16 | DropdownTrigger,
17 | Input,
18 | ModalBody,
19 | ModalContent,
20 | ModalFooter,
21 | ModalHeader,
22 | Select,
23 | SelectItem,
24 | useDisclosure
25 | } from '@nextui-org/react'
26 | import { IconPlus, IconTrash } from '@tabler/icons-react'
27 | import { differenceWith } from 'lodash'
28 | import { FC, Fragment, useMemo } from 'react'
29 | import { Controller, useForm } from 'react-hook-form'
30 | import { useTranslation } from 'react-i18next'
31 | import { z } from 'zod'
32 | import { Policy } from '~/apis/gql/graphql'
33 | import {
34 | useCreateGroupMutation,
35 | useGroupAddNodesMutation,
36 | useGroupAddSubscriptionsMutation,
37 | useGroupDelNodesMutation,
38 | useGroupDelSubscriptionsMutation,
39 | useGroupSetPolicyMutation,
40 | useRemoveGroupsMutation
41 | } from '~/apis/mutation'
42 | import { useGroupsQuery } from '~/apis/query'
43 | import { Group, Node, Subscription } from '~/app/(protected)/network/typings'
44 | import { Button } from '~/components/Button'
45 | import { Modal, ModalConfirmFormFooter } from '~/components/Modal'
46 | import { groupFormDefault, groupFormSchema } from '~/schemas/group'
47 |
48 | const GroupContent: FC<{
49 | group: Group
50 | subscriptions: Subscription[]
51 | nodes: Node[]
52 | }> = ({ group, subscriptions, nodes }) => {
53 | const { t } = useTranslation()
54 |
55 | // subscriptions
56 | const allSubscriptions = useMemo(() => subscriptions.map(({ id }) => id), [subscriptions])
57 | const selectedSubscriptions = useMemo(
58 | () => group.subscriptions.map((subscription) => subscription.id),
59 | [group.subscriptions]
60 | )
61 | const groupAddSubscriptionsMutation = useGroupAddSubscriptionsMutation()
62 | const groupDelSubscriptionsMutation = useGroupDelSubscriptionsMutation()
63 |
64 | // nodes
65 | const allNodes = useMemo(() => nodes.map(({ id }) => id), [nodes])
66 | const selectedNodes = useMemo(() => group.nodes.map((node) => node.id), [group.nodes])
67 | const groupAddNodesMutation = useGroupAddNodesMutation()
68 | const groupDelNodesMutation = useGroupDelNodesMutation()
69 |
70 | return (
71 |
72 |
77 |
145 |
146 |
147 |
152 |
210 |
211 |
212 | )
213 | }
214 |
215 | const usePolicies = () => {
216 | const { t } = useTranslation()
217 |
218 | return [
219 | {
220 | label: Policy.MinMovingAvg,
221 | value: Policy.MinMovingAvg,
222 | description: t('form.descriptions.group.MinMovingAvg')
223 | },
224 | {
225 | label: Policy.MinAvg10,
226 | value: Policy.MinAvg10,
227 | description: t('form.descriptions.group.MinAvg10')
228 | },
229 | {
230 | label: Policy.Min,
231 | value: Policy.Min,
232 | description: t('form.descriptions.group.Min')
233 | },
234 | {
235 | label: Policy.Random,
236 | value: Policy.Random,
237 | description: t('form.descriptions.group.Random')
238 | }
239 | ]
240 | }
241 |
242 | const GroupCard: FC<{
243 | group: Group
244 | subscriptions: Subscription[]
245 | nodes: Node[]
246 | }> = ({ group, subscriptions, nodes }) => {
247 | const { t } = useTranslation()
248 |
249 | const {
250 | isOpen: isRemoveGroupOpen,
251 | onOpen: onRemoveGroupOpen,
252 | onClose: onRemoveGroupClose,
253 | onOpenChange: onRemoveGroupOpenChange
254 | } = useDisclosure()
255 |
256 | const policies = usePolicies()
257 |
258 | const groupSetPolicyMutation = useGroupSetPolicyMutation()
259 | const removeGroupsMutation = useRemoveGroupsMutation()
260 |
261 | return (
262 |
263 |
264 | {group.name}
265 |
266 |
267 |
268 |
269 |
272 |
273 |
274 | {
280 | if (selected === 'all') return
281 |
282 | await groupSetPolicyMutation.mutateAsync({
283 | id: group.id,
284 | policy: Array.from(selected)[0] as Policy,
285 | policyParams: []
286 | })
287 | }}
288 | >
289 | {policies.map((policy) => (
290 |
291 | {policy.label}
292 |
293 | ))}
294 |
295 |
296 |
297 |
300 |
301 |
302 |
303 | {t('primitives.remove', { resourceName: t('primitives.group') })}
304 | {group.name}
305 |
306 | {
310 | await removeGroupsMutation.mutateAsync({
311 | groupIDs: [group.id]
312 | })
313 |
314 | onRemoveGroupClose()
315 | }}
316 | />
317 |
318 |
319 |
320 |
321 |
322 |
323 |
324 |
325 |
326 |
327 |
328 | )
329 | }
330 |
331 | export const GroupSection: FC<{ nodes: Node[]; subscriptions: Subscription[] }> = ({ nodes, subscriptions }) => {
332 | const { t } = useTranslation()
333 |
334 | const { isOpen: isAddOpen, onOpen: onAddOpen, onClose: onAddClose, onOpenChange: onAddOpenChange } = useDisclosure()
335 |
336 | const groupsQuery = useGroupsQuery()
337 |
338 | const {
339 | register,
340 | control,
341 | handleSubmit,
342 | reset,
343 | formState: { errors, dirtyFields, isSubmitting }
344 | } = useForm>({
345 | shouldFocusError: true,
346 | resolver: zodResolver(groupFormSchema),
347 | defaultValues: groupFormDefault
348 | })
349 | const createGroupFormDirty = Object.values(dirtyFields).some((dirty) => dirty)
350 |
351 | const createGroupMutation = useCreateGroupMutation()
352 |
353 | const policies = usePolicies()
354 |
355 | return (
356 |
357 |
358 |
{t('primitives.group')}
359 |
360 |
361 |
364 |
365 |
366 |
426 |
427 |
428 |
429 |
430 | {groupsQuery.data && (
431 |
1 ? 'sm:grid-cols-2' : 'sm:grid-cols-1'
435 | )}
436 | >
437 | {groupsQuery.data?.groups.map((group) => (
438 |
439 | ))}
440 |
441 | )}
442 |
443 | )
444 | }
445 |
--------------------------------------------------------------------------------
/src/apis/mutation.ts:
--------------------------------------------------------------------------------
1 | import { useMutation, useQueryClient } from '@tanstack/react-query'
2 | import { graphql } from '~/apis/gql'
3 | import { GlobalInput, ImportArgument, Policy, PolicyParam } from '~/apis/gql/graphql'
4 | import {
5 | configsQueryKey,
6 | dnssQueryKey,
7 | generalQueryKey,
8 | groupsQueryKey,
9 | nodesQueryKey,
10 | routingsQueryKey,
11 | subscriptionsQueryKey,
12 | userQueryKey
13 | } from '~/apis/query'
14 | import { useGraphqlClient } from '~/contexts'
15 |
16 | export const setJsonStorageMutation = graphql(`
17 | mutation SetJsonStorage($paths: [String!]!, $values: [String!]!) {
18 | setJsonStorage(paths: $paths, values: $values)
19 | }
20 | `)
21 |
22 | export const createConfigMutation = graphql(`
23 | mutation CreateConfig($name: String, $global: globalInput) {
24 | createConfig(name: $name, global: $global) {
25 | id
26 | }
27 | }
28 | `)
29 |
30 | export const useCreateConfigMutation = () => {
31 | const gqlClient = useGraphqlClient()
32 | const queryClient = useQueryClient()
33 |
34 | return useMutation({
35 | mutationFn: ({ name, global }: { name?: string; global?: GlobalInput }) =>
36 | gqlClient.request(createConfigMutation, {
37 | name,
38 | global
39 | }),
40 | onSuccess: () => {
41 | void queryClient.invalidateQueries({ queryKey: configsQueryKey })
42 | }
43 | })
44 | }
45 |
46 | export const useUpdateConfigMutation = () => {
47 | const gqlClient = useGraphqlClient()
48 | const queryClient = useQueryClient()
49 |
50 | return useMutation({
51 | mutationFn: ({ id, global }: { id: string; global: GlobalInput }) => {
52 | return gqlClient.request(
53 | graphql(`
54 | mutation UpdateConfig($id: ID!, $global: globalInput!) {
55 | updateConfig(id: $id, global: $global) {
56 | id
57 | }
58 | }
59 | `),
60 | {
61 | id,
62 | global
63 | }
64 | )
65 | },
66 | onSuccess: () => {
67 | void queryClient.invalidateQueries({ queryKey: configsQueryKey })
68 | void queryClient.invalidateQueries({ queryKey: generalQueryKey })
69 | }
70 | })
71 | }
72 |
73 | export const useRemoveConfigMutation = () => {
74 | const gqlClient = useGraphqlClient()
75 | const queryClient = useQueryClient()
76 |
77 | return useMutation({
78 | mutationFn: ({ id }: { id: string }) => {
79 | return gqlClient.request(
80 | graphql(`
81 | mutation RemoveConfig($id: ID!) {
82 | removeConfig(id: $id)
83 | }
84 | `),
85 | {
86 | id
87 | }
88 | )
89 | },
90 | onSuccess: () => {
91 | void queryClient.invalidateQueries({ queryKey: configsQueryKey })
92 | }
93 | })
94 | }
95 |
96 | export const selectConfigMutation = graphql(`
97 | mutation SelectConfig($id: ID!) {
98 | selectConfig(id: $id)
99 | }
100 | `)
101 |
102 | export const useSelectConfigMutation = () => {
103 | const gqlClient = useGraphqlClient()
104 | const queryClient = useQueryClient()
105 |
106 | return useMutation({
107 | mutationFn: ({ id }: { id: string }) => {
108 | return gqlClient.request(selectConfigMutation, {
109 | id
110 | })
111 | },
112 | onSuccess: () => {
113 | void queryClient.invalidateQueries({ queryKey: configsQueryKey })
114 | void queryClient.invalidateQueries({ queryKey: generalQueryKey })
115 | }
116 | })
117 | }
118 |
119 | export const useRenameConfigMutation = () => {
120 | const gqlClient = useGraphqlClient()
121 | const queryClient = useQueryClient()
122 |
123 | return useMutation({
124 | mutationFn: ({ id, name }: { id: string; name: string }) => {
125 | return gqlClient.request(
126 | graphql(`
127 | mutation RenameConfig($id: ID!, $name: String!) {
128 | renameConfig(id: $id, name: $name)
129 | }
130 | `),
131 | {
132 | id,
133 | name
134 | }
135 | )
136 | },
137 | onSuccess: () => {
138 | void queryClient.invalidateQueries({ queryKey: configsQueryKey })
139 | }
140 | })
141 | }
142 |
143 | export const createRoutingMutation = graphql(`
144 | mutation CreateRouting($name: String, $routing: String) {
145 | createRouting(name: $name, routing: $routing) {
146 | id
147 | }
148 | }
149 | `)
150 |
151 | export const useCreateRoutingMutation = () => {
152 | const gqlClient = useGraphqlClient()
153 | const queryClient = useQueryClient()
154 |
155 | return useMutation({
156 | mutationFn: ({ name, routing }: { name?: string; routing?: string }) => {
157 | return gqlClient.request(createRoutingMutation, {
158 | name,
159 | routing
160 | })
161 | },
162 | onSuccess: () => {
163 | void queryClient.invalidateQueries({ queryKey: routingsQueryKey })
164 | }
165 | })
166 | }
167 |
168 | export const useUpdateRoutingMutation = () => {
169 | const gqlClient = useGraphqlClient()
170 | const queryClient = useQueryClient()
171 |
172 | return useMutation({
173 | mutationFn: ({ id, routing }: { id: string; routing: string }) => {
174 | return gqlClient.request(
175 | graphql(`
176 | mutation UpdateRouting($id: ID!, $routing: String!) {
177 | updateRouting(id: $id, routing: $routing) {
178 | id
179 | }
180 | }
181 | `),
182 | {
183 | id,
184 | routing
185 | }
186 | )
187 | },
188 | onSuccess: () => {
189 | void queryClient.invalidateQueries({ queryKey: routingsQueryKey })
190 | void queryClient.invalidateQueries({ queryKey: generalQueryKey })
191 | }
192 | })
193 | }
194 |
195 | export const useRemoveRoutingMutation = () => {
196 | const gqlClient = useGraphqlClient()
197 | const queryClient = useQueryClient()
198 |
199 | return useMutation({
200 | mutationFn: ({ id }: { id: string }) => {
201 | return gqlClient.request(
202 | graphql(`
203 | mutation RemoveRouting($id: ID!) {
204 | removeRouting(id: $id)
205 | }
206 | `),
207 | {
208 | id
209 | }
210 | )
211 | },
212 | onSuccess: () => {
213 | void queryClient.invalidateQueries({ queryKey: routingsQueryKey })
214 | void queryClient.invalidateQueries({ queryKey: generalQueryKey })
215 | }
216 | })
217 | }
218 |
219 | export const selectRoutingMutation = graphql(`
220 | mutation SelectRouting($id: ID!) {
221 | selectRouting(id: $id)
222 | }
223 | `)
224 |
225 | export const useSelectRoutingMutation = () => {
226 | const gqlClient = useGraphqlClient()
227 | const queryClient = useQueryClient()
228 |
229 | return useMutation({
230 | mutationFn: ({ id }: { id: string }) => {
231 | return gqlClient.request(selectRoutingMutation, {
232 | id
233 | })
234 | },
235 | onSuccess: () => {
236 | void queryClient.invalidateQueries({ queryKey: routingsQueryKey })
237 | void queryClient.invalidateQueries({ queryKey: generalQueryKey })
238 | }
239 | })
240 | }
241 |
242 | export const useRenameRoutingMutation = () => {
243 | const gqlClient = useGraphqlClient()
244 | const queryClient = useQueryClient()
245 |
246 | return useMutation({
247 | mutationFn: ({ id, name }: { id: string; name: string }) => {
248 | return gqlClient.request(
249 | graphql(`
250 | mutation RenameRouting($id: ID!, $name: String!) {
251 | renameRouting(id: $id, name: $name)
252 | }
253 | `),
254 | {
255 | id,
256 | name
257 | }
258 | )
259 | },
260 | onSuccess: () => {
261 | void queryClient.invalidateQueries({ queryKey: routingsQueryKey })
262 | void queryClient.invalidateQueries({ queryKey: generalQueryKey })
263 | }
264 | })
265 | }
266 |
267 | export const createDNSMutation = graphql(`
268 | mutation CreateDNS($name: String, $dns: String) {
269 | createDns(name: $name, dns: $dns) {
270 | id
271 | }
272 | }
273 | `)
274 |
275 | export const useCreateDNSMutation = () => {
276 | const gqlClient = useGraphqlClient()
277 | const queryClient = useQueryClient()
278 |
279 | return useMutation({
280 | mutationFn: ({ name, dns }: { name?: string; dns?: string }) => {
281 | return gqlClient.request(createDNSMutation, {
282 | name,
283 | dns
284 | })
285 | },
286 | onSuccess: () => {
287 | void queryClient.invalidateQueries({ queryKey: dnssQueryKey })
288 | }
289 | })
290 | }
291 |
292 | export const useUpdateDNSMutation = () => {
293 | const gqlClient = useGraphqlClient()
294 | const queryClient = useQueryClient()
295 |
296 | return useMutation({
297 | mutationFn: ({ id, dns }: { id: string; dns: string }) => {
298 | return gqlClient.request(
299 | graphql(`
300 | mutation UpdateDNS($id: ID!, $dns: String!) {
301 | updateDns(id: $id, dns: $dns) {
302 | id
303 | }
304 | }
305 | `),
306 | {
307 | id,
308 | dns
309 | }
310 | )
311 | },
312 | onSuccess: () => {
313 | void queryClient.invalidateQueries({ queryKey: dnssQueryKey })
314 | void queryClient.invalidateQueries({ queryKey: generalQueryKey })
315 | }
316 | })
317 | }
318 |
319 | export const useRemoveDNSMutation = () => {
320 | const gqlClient = useGraphqlClient()
321 | const queryClient = useQueryClient()
322 |
323 | return useMutation({
324 | mutationFn: ({ id }: { id: string }) => {
325 | return gqlClient.request(
326 | graphql(`
327 | mutation RemoveDNS($id: ID!) {
328 | removeDns(id: $id)
329 | }
330 | `),
331 | {
332 | id
333 | }
334 | )
335 | },
336 | onSuccess: () => {
337 | void queryClient.invalidateQueries({ queryKey: dnssQueryKey })
338 | }
339 | })
340 | }
341 |
342 | export const selectDNSMutation = graphql(`
343 | mutation SelectDNS($id: ID!) {
344 | selectDns(id: $id)
345 | }
346 | `)
347 |
348 | export const useSelectDNSMutation = () => {
349 | const gqlClient = useGraphqlClient()
350 | const queryClient = useQueryClient()
351 |
352 | return useMutation({
353 | mutationFn: ({ id }: { id: string }) => {
354 | return gqlClient.request(selectDNSMutation, {
355 | id
356 | })
357 | },
358 | onSuccess: () => {
359 | void queryClient.invalidateQueries({ queryKey: dnssQueryKey })
360 | void queryClient.invalidateQueries({ queryKey: generalQueryKey })
361 | }
362 | })
363 | }
364 |
365 | export const useRenameDNSMutation = () => {
366 | const gqlClient = useGraphqlClient()
367 | const queryClient = useQueryClient()
368 |
369 | return useMutation({
370 | mutationFn: ({ id, name }: { id: string; name: string }) => {
371 | return gqlClient.request(
372 | graphql(`
373 | mutation RenameDNS($id: ID!, $name: String!) {
374 | renameDns(id: $id, name: $name)
375 | }
376 | `),
377 | {
378 | id,
379 | name
380 | }
381 | )
382 | },
383 | onSuccess: () => {
384 | void queryClient.invalidateQueries({ queryKey: dnssQueryKey })
385 | }
386 | })
387 | }
388 |
389 | export const createGroupMutation = graphql(`
390 | mutation CreateGroup($name: String!, $policy: Policy!, $policyParams: [PolicyParam!]) {
391 | createGroup(name: $name, policy: $policy, policyParams: $policyParams) {
392 | id
393 | }
394 | }
395 | `)
396 |
397 | export const useCreateGroupMutation = () => {
398 | const gqlClient = useGraphqlClient()
399 | const queryClient = useQueryClient()
400 |
401 | return useMutation({
402 | mutationFn: ({ name, policy, policyParams }: { name: string; policy: Policy; policyParams: PolicyParam[] }) => {
403 | return gqlClient.request(createGroupMutation, {
404 | name,
405 | policy,
406 | policyParams
407 | })
408 | },
409 | onSuccess: () => {
410 | void queryClient.invalidateQueries({ queryKey: groupsQueryKey })
411 | }
412 | })
413 | }
414 |
415 | export const useRemoveGroupsMutation = () => {
416 | const gqlClient = useGraphqlClient()
417 | const queryClient = useQueryClient()
418 |
419 | return useMutation({
420 | mutationFn: ({ groupIDs }: { groupIDs: string[] }) => {
421 | return Promise.all(
422 | groupIDs.map((id) =>
423 | gqlClient.request(
424 | graphql(`
425 | mutation RemoveGroup($id: ID!) {
426 | removeGroup(id: $id)
427 | }
428 | `),
429 | {
430 | id
431 | }
432 | )
433 | )
434 | )
435 | },
436 | onSuccess: () => {
437 | void queryClient.invalidateQueries({ queryKey: groupsQueryKey })
438 | }
439 | })
440 | }
441 |
442 | export const useGroupSetPolicyMutation = () => {
443 | const gqlClient = useGraphqlClient()
444 | const queryClient = useQueryClient()
445 |
446 | return useMutation({
447 | mutationFn: ({ id, policy, policyParams }: { id: string; policy: Policy; policyParams: PolicyParam[] }) => {
448 | return gqlClient.request(
449 | graphql(`
450 | mutation GroupSetPolicy($id: ID!, $policy: Policy!, $policyParams: [PolicyParam!]) {
451 | groupSetPolicy(id: $id, policy: $policy, policyParams: $policyParams)
452 | }
453 | `),
454 | {
455 | id,
456 | policy,
457 | policyParams
458 | }
459 | )
460 | },
461 | onSuccess: () => {
462 | void queryClient.invalidateQueries({ queryKey: groupsQueryKey })
463 | void queryClient.invalidateQueries({ queryKey: generalQueryKey })
464 | }
465 | })
466 | }
467 |
468 | export const useRenameGroupMutation = () => {
469 | const gqlClient = useGraphqlClient()
470 | const queryClient = useQueryClient()
471 |
472 | return useMutation({
473 | mutationFn: ({ id, name }: { id: string; name: string }) => {
474 | return gqlClient.request(
475 | graphql(`
476 | mutation RenameGroup($id: ID!, $name: String!) {
477 | renameGroup(id: $id, name: $name)
478 | }
479 | `),
480 | {
481 | id,
482 | name
483 | }
484 | )
485 | },
486 | onSuccess: () => {
487 | void queryClient.invalidateQueries({ queryKey: groupsQueryKey })
488 | void queryClient.invalidateQueries({ queryKey: generalQueryKey })
489 | }
490 | })
491 | }
492 |
493 | export const useGroupAddNodesMutation = () => {
494 | const gqlClient = useGraphqlClient()
495 | const queryClient = useQueryClient()
496 |
497 | return useMutation({
498 | mutationFn: ({ id, nodeIDs }: { id: string; nodeIDs: string[] }) => {
499 | return gqlClient.request(
500 | graphql(`
501 | mutation GroupAddNodes($id: ID!, $nodeIDs: [ID!]!) {
502 | groupAddNodes(id: $id, nodeIDs: $nodeIDs)
503 | }
504 | `),
505 | {
506 | id,
507 | nodeIDs
508 | }
509 | )
510 | },
511 | onSuccess: () => {
512 | void queryClient.invalidateQueries({ queryKey: groupsQueryKey })
513 | void queryClient.invalidateQueries({ queryKey: generalQueryKey })
514 | }
515 | })
516 | }
517 |
518 | export const useGroupDelNodesMutation = () => {
519 | const gqlClient = useGraphqlClient()
520 | const queryClient = useQueryClient()
521 |
522 | return useMutation({
523 | mutationFn: ({ id, nodeIDs }: { id: string; nodeIDs: string[] }) => {
524 | return gqlClient.request(
525 | graphql(`
526 | mutation GroupDelNodes($id: ID!, $nodeIDs: [ID!]!) {
527 | groupDelNodes(id: $id, nodeIDs: $nodeIDs)
528 | }
529 | `),
530 | {
531 | id,
532 | nodeIDs
533 | }
534 | )
535 | },
536 | onSuccess: () => {
537 | void queryClient.invalidateQueries({ queryKey: groupsQueryKey })
538 | void queryClient.invalidateQueries({ queryKey: generalQueryKey })
539 | }
540 | })
541 | }
542 |
543 | export const useGroupAddSubscriptionsMutation = () => {
544 | const gqlClient = useGraphqlClient()
545 | const queryClient = useQueryClient()
546 |
547 | return useMutation({
548 | mutationFn: ({ id, subscriptionIDs }: { id: string; subscriptionIDs: string[] }) => {
549 | return gqlClient.request(
550 | graphql(`
551 | mutation GroupAddSubscriptions($id: ID!, $subscriptionIDs: [ID!]!) {
552 | groupAddSubscriptions(id: $id, subscriptionIDs: $subscriptionIDs)
553 | }
554 | `),
555 | {
556 | id,
557 | subscriptionIDs
558 | }
559 | )
560 | },
561 | onSuccess: () => {
562 | void queryClient.invalidateQueries({ queryKey: groupsQueryKey })
563 | void queryClient.invalidateQueries({ queryKey: generalQueryKey })
564 | }
565 | })
566 | }
567 |
568 | export const useGroupDelSubscriptionsMutation = () => {
569 | const gqlClient = useGraphqlClient()
570 | const queryClient = useQueryClient()
571 |
572 | return useMutation({
573 | mutationFn: ({ id, subscriptionIDs }: { id: string; subscriptionIDs: string[] }) => {
574 | return gqlClient.request(
575 | graphql(`
576 | mutation GroupDelSubscriptions($id: ID!, $subscriptionIDs: [ID!]!) {
577 | groupDelSubscriptions(id: $id, subscriptionIDs: $subscriptionIDs)
578 | }
579 | `),
580 | {
581 | id,
582 | subscriptionIDs
583 | }
584 | )
585 | },
586 | onSuccess: () => {
587 | void queryClient.invalidateQueries({ queryKey: groupsQueryKey })
588 | void queryClient.invalidateQueries({ queryKey: generalQueryKey })
589 | }
590 | })
591 | }
592 |
593 | export const useImportNodesMutation = () => {
594 | const gqlClient = useGraphqlClient()
595 | const queryClient = useQueryClient()
596 |
597 | return useMutation({
598 | mutationFn: (data: ImportArgument[]) => {
599 | return gqlClient.request(
600 | graphql(`
601 | mutation ImportNodes($rollbackError: Boolean!, $args: [ImportArgument!]!) {
602 | importNodes(rollbackError: $rollbackError, args: $args) {
603 | link
604 | error
605 | node {
606 | id
607 | }
608 | }
609 | }
610 | `),
611 | {
612 | rollbackError: false,
613 | args: data
614 | }
615 | )
616 | },
617 | onSuccess: () => {
618 | void queryClient.invalidateQueries({ queryKey: nodesQueryKey })
619 | }
620 | })
621 | }
622 |
623 | export const useRemoveNodesMutation = () => {
624 | const gqlClient = useGraphqlClient()
625 | const queryClient = useQueryClient()
626 |
627 | return useMutation({
628 | mutationFn: ({ nodeIDs }: { nodeIDs: string[] }) => {
629 | return gqlClient.request(
630 | graphql(`
631 | mutation RemoveNodes($ids: [ID!]!) {
632 | removeNodes(ids: $ids)
633 | }
634 | `),
635 | {
636 | ids: nodeIDs
637 | }
638 | )
639 | },
640 | onSuccess: () => {
641 | void queryClient.invalidateQueries({ queryKey: nodesQueryKey })
642 | void queryClient.invalidateQueries({ queryKey: groupsQueryKey })
643 | void queryClient.invalidateQueries({ queryKey: configsQueryKey })
644 | }
645 | })
646 | }
647 |
648 | export const useImportSubscriptionsMutation = () => {
649 | const gqlClient = useGraphqlClient()
650 | const queryClient = useQueryClient()
651 |
652 | return useMutation({
653 | mutationFn: (data: ImportArgument[]) =>
654 | Promise.all(
655 | data.map((subscription) =>
656 | gqlClient.request(
657 | graphql(`
658 | mutation ImportSubscription($rollbackError: Boolean!, $arg: ImportArgument!) {
659 | importSubscription(rollbackError: $rollbackError, arg: $arg) {
660 | link
661 | sub {
662 | id
663 | }
664 | nodeImportResult {
665 | node {
666 | id
667 | }
668 | }
669 | }
670 | }
671 | `),
672 | {
673 | rollbackError: false,
674 | arg: subscription
675 | }
676 | )
677 | )
678 | ),
679 | onSuccess: () => {
680 | void queryClient.invalidateQueries({ queryKey: subscriptionsQueryKey })
681 | }
682 | })
683 | }
684 |
685 | export const useUpdateSubscriptionsMutation = () => {
686 | const gqlClient = useGraphqlClient()
687 | const queryClient = useQueryClient()
688 |
689 | return useMutation({
690 | mutationFn: ({ subscriptionIDs }: { subscriptionIDs: string[] }) =>
691 | Promise.all(
692 | subscriptionIDs.map((id) =>
693 | gqlClient.request(
694 | graphql(`
695 | mutation UpdateSubscription($id: ID!) {
696 | updateSubscription(id: $id) {
697 | id
698 | }
699 | }
700 | `),
701 | {
702 | id
703 | }
704 | )
705 | )
706 | ),
707 | onSuccess: () => {
708 | void queryClient.invalidateQueries({ queryKey: subscriptionsQueryKey })
709 | void queryClient.invalidateQueries({ queryKey: groupsQueryKey })
710 | void queryClient.invalidateQueries({ queryKey: generalQueryKey })
711 | }
712 | })
713 | }
714 |
715 | export const useRemoveSubscriptionsMutation = () => {
716 | const gqlClient = useGraphqlClient()
717 | const queryClient = useQueryClient()
718 |
719 | return useMutation({
720 | mutationFn: ({ subscriptionIDs }: { subscriptionIDs: string[] }) =>
721 | gqlClient.request(
722 | graphql(`
723 | mutation RemoveSubscriptions($ids: [ID!]!) {
724 | removeSubscriptions(ids: $ids)
725 | }
726 | `),
727 | {
728 | ids: subscriptionIDs
729 | }
730 | ),
731 | onSuccess: () => {
732 | void queryClient.invalidateQueries({ queryKey: subscriptionsQueryKey })
733 | void queryClient.invalidateQueries({ queryKey: groupsQueryKey })
734 | void queryClient.invalidateQueries({ queryKey: generalQueryKey })
735 | }
736 | })
737 | }
738 |
739 | export const useRunMutation = () => {
740 | const gqlClient = useGraphqlClient()
741 | const queryClient = useQueryClient()
742 |
743 | return useMutation({
744 | mutationFn: (dry: boolean) => {
745 | return gqlClient.request(
746 | graphql(`
747 | mutation Run($dry: Boolean!) {
748 | run(dry: $dry)
749 | }
750 | `),
751 | {
752 | dry
753 | }
754 | )
755 | },
756 | onSuccess: () => {
757 | void queryClient.invalidateQueries({ queryKey: generalQueryKey })
758 | }
759 | })
760 | }
761 |
762 | export const useUpdateAvatarMutation = () => {
763 | const gqlClient = useGraphqlClient()
764 | const queryClient = useQueryClient()
765 |
766 | return useMutation({
767 | mutationFn: (avatar: string) => {
768 | return gqlClient.request(
769 | graphql(`
770 | mutation UpdateAvatar($avatar: String) {
771 | updateAvatar(avatar: $avatar)
772 | }
773 | `),
774 | {
775 | avatar
776 | }
777 | )
778 | },
779 | onSuccess: () => {
780 | void queryClient.invalidateQueries({ queryKey: userQueryKey })
781 | }
782 | })
783 | }
784 |
785 | export const useUpdateNameMutation = () => {
786 | const gqlClient = useGraphqlClient()
787 | const queryClient = useQueryClient()
788 |
789 | return useMutation({
790 | mutationFn: (name: string) => {
791 | return gqlClient.request(
792 | graphql(`
793 | mutation UpdateName($name: String) {
794 | updateName(name: $name)
795 | }
796 | `),
797 | {
798 | name
799 | }
800 | )
801 | },
802 | onSuccess: () => {
803 | void queryClient.invalidateQueries({ queryKey: userQueryKey })
804 | }
805 | })
806 | }
807 |
808 | export const updatePasswordMutation = graphql(`
809 | mutation UpdatePassword($currentPassword: String!, $newPassword: String!) {
810 | updatePassword(currentPassword: $currentPassword, newPassword: $newPassword)
811 | }
812 | `)
813 |
814 | export const useUpdatePasswordMutation = () => {
815 | const gqlClient = useGraphqlClient()
816 |
817 | return useMutation({
818 | mutationFn: ({ currentPassword, newPassword }: { currentPassword: string; newPassword: string }) => {
819 | return gqlClient.request(updatePasswordMutation, { currentPassword, newPassword })
820 | }
821 | })
822 | }
823 |
--------------------------------------------------------------------------------