hy2-panel/app/admin/subscriptions/proxy-group-editor.tsx

293 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useDeferredValue, useState } from "react";
import { useFormStatus } from "react-dom";
import { deleteProxyGroupAction, saveProxyGroupAction } from "./actions";
type ProxyGroupNodeOption = {
slug: string;
label: string;
meta: string;
};
type ProxyGroupEditorProps = {
group?: {
id: string;
name: string;
memberSlugs: string[];
};
nodes: ProxyGroupNodeOption[];
};
function normalizeSelectedSlugs(initialSlugs: string[], nodes: ProxyGroupNodeOption[]) {
const availableSlugs = new Set(nodes.map((node) => node.slug));
const seen = new Set<string>();
return initialSlugs.filter((slug) => {
if (!availableSlugs.has(slug) || seen.has(slug)) {
return false;
}
seen.add(slug);
return true;
});
}
function SaveButton({ isEditing }: { isEditing: boolean }) {
const { pending } = useFormStatus();
return (
<button className={isEditing ? "ghost-button" : "primary-button"} type="submit" disabled={pending}>
{pending ? (isEditing ? "保存中..." : "创建中...") : isEditing ? "保存分组" : "新建分组"}
</button>
);
}
function DeleteButton() {
const { pending } = useFormStatus();
return (
<button className="danger-button" type="submit" disabled={pending}>
{pending ? "删除中..." : "删除分组"}
</button>
);
}
export function ProxyGroupEditor({ group, nodes }: ProxyGroupEditorProps) {
const isEditing = Boolean(group);
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);
const [selectedSlugs, setSelectedSlugs] = useState(() =>
normalizeSelectedSlugs(group?.memberSlugs ?? [], nodes),
);
const selectedSlugSet = new Set(selectedSlugs);
const nodesBySlug = new Map(nodes.map((node) => [node.slug, node]));
const selectedNodes = selectedSlugs
.map((slug) => nodesBySlug.get(slug))
.filter((node): node is ProxyGroupNodeOption => Boolean(node));
const normalizedQuery = deferredQuery.trim().toLowerCase();
const remainingCount = nodes.length - selectedNodes.length;
const availableNodes = nodes.filter((node) => {
if (selectedSlugSet.has(node.slug)) {
return false;
}
if (!normalizedQuery) {
return true;
}
return [node.label, node.meta, node.slug].some((value) =>
value.toLowerCase().includes(normalizedQuery),
);
});
function addNode(slug: string) {
setSelectedSlugs((current) => {
if (current.includes(slug)) {
return current;
}
return [...current, slug];
});
}
function removeNode(slug: string) {
setSelectedSlugs((current) => current.filter((currentSlug) => currentSlug !== slug));
}
function moveNode(slug: string, direction: -1 | 1) {
setSelectedSlugs((current) => {
const index = current.indexOf(slug);
const nextIndex = index + direction;
if (index === -1 || nextIndex < 0 || nextIndex >= current.length) {
return current;
}
const next = [...current];
[next[index], next[nextIndex]] = [next[nextIndex], next[index]];
return next;
});
}
function addAllVisibleNodes() {
setSelectedSlugs((current) => {
const next = [...current];
const seen = new Set(current);
for (const node of availableNodes) {
if (seen.has(node.slug)) {
continue;
}
next.push(node.slug);
seen.add(node.slug);
}
return next;
});
}
function clearSelection() {
setSelectedSlugs([]);
}
return (
<article className="instruction-block proxy-group-card">
<form action={saveProxyGroupAction} className="stack-md">
{group ? <input type="hidden" name="id" value={group.id} /> : null}
<div className="section-title section-title--top">
<div className="stack-tight">
<h4 className="proxy-group-title">{isEditing ? group?.name : "新建分组"}</h4>
<p className="muted proxy-group-caption"> Clash </p>
</div>
<div className="badge-row badge-row--end">
<span className="status-pill status-pill--ink"> {selectedNodes.length}</span>
<span className="status-pill status-pill--warm"> {remainingCount}</span>
</div>
</div>
<label className="field">
<span>{isEditing ? "组名" : "新建分组名"}</span>
<input name="name" defaultValue={group?.name ?? ""} placeholder="ChatGPT" required />
</label>
<div className="proxy-group-builder">
<section className="proxy-group-panel">
<div className="proxy-group-panel__header">
<div className="stack-tight">
<strong></strong>
<span className="muted"></span>
</div>
<div className="proxy-group-toolbar">
<label className="proxy-group-search">
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
}
}}
placeholder="搜索节点名称 / slug"
/>
</label>
<button
className="ghost-button proxy-toolbar-button"
type="button"
onClick={addAllVisibleNodes}
disabled={availableNodes.length === 0}
>
{normalizedQuery ? "添加筛选结果" : "全部添加"}
</button>
</div>
</div>
{availableNodes.length > 0 ? (
<div className="proxy-node-grid">
{availableNodes.map((node) => (
<button
key={node.slug}
className="proxy-node-chip"
type="button"
onClick={() => addNode(node.slug)}
>
<span className="proxy-node-chip__copy">
<strong>{node.label}</strong>
<span>{node.meta}</span>
</span>
<span className="proxy-node-chip__action"></span>
</button>
))}
</div>
) : (
<p className="muted proxy-group-empty">
{remainingCount === 0
? "所有可用节点都已加入当前分组。"
: "没有匹配当前筛选条件的节点。"}
</p>
)}
</section>
<section className="proxy-group-panel proxy-group-panel--selected">
<div className="proxy-group-panel__header">
<div className="stack-tight">
<strong></strong>
<span className="muted"></span>
</div>
<button
className="ghost-button proxy-toolbar-button"
type="button"
onClick={clearSelection}
disabled={selectedNodes.length === 0}
>
</button>
</div>
{selectedNodes.length > 0 ? (
<ol className="proxy-sort-list">
{selectedNodes.map((node, index) => (
<li key={node.slug} className="proxy-sort-item">
<div className="proxy-sort-item__meta">
<span className="proxy-sort-item__index">{String(index + 1).padStart(2, "0")}</span>
<div className="stack-tight">
<strong>{node.label}</strong>
<span className="muted mono proxy-sort-item__sub">{node.meta}</span>
</div>
</div>
<div className="proxy-sort-item__actions">
<button
className="ghost-button proxy-sort-button"
type="button"
onClick={() => moveNode(node.slug, -1)}
disabled={index === 0}
>
</button>
<button
className="ghost-button proxy-sort-button"
type="button"
onClick={() => moveNode(node.slug, 1)}
disabled={index === selectedNodes.length - 1}
>
</button>
<button
className="danger-button proxy-sort-button"
type="button"
onClick={() => removeNode(node.slug)}
>
</button>
</div>
<input type="hidden" name="member_slugs" value={node.slug} />
</li>
))}
</ol>
) : (
<p className="muted proxy-group-empty">
</p>
)}
</section>
</div>
<div className="action-row">
<span className="muted"> proxy-groups</span>
<SaveButton isEditing={isEditing} />
</div>
</form>
{group ? (
<form action={deleteProxyGroupAction}>
<input type="hidden" name="id" value={group.id} />
<DeleteButton />
</form>
) : null}
</article>
);
}