Skip to content

Commit a2e61ad

Browse files
committed
refactor: share subscription source controller
1 parent 2b75909 commit a2e61ad

3 files changed

Lines changed: 339 additions & 487 deletions

File tree

packages/ui/src/product/converter/advanced-mode/sections/input-section.tsx

Lines changed: 20 additions & 249 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,16 @@
11
"use client";
22

3-
import * as React from "react";
43
import * as Popover from "@radix-ui/react-popover";
54
import { AlertCircle, Check, HelpCircle, Loader2, Maximize2, Plus, Server, X, Menu, ChevronUp, ChevronDown } from "lucide-react";
65
import { Badge } from "@subboost/ui/components/ui/badge";
76
import { Button } from "@subboost/ui/components/ui/button";
87
import { Input } from "@subboost/ui/components/ui/input";
98
import { Textarea } from "@subboost/ui/components/ui/textarea";
10-
import { toast } from "@subboost/ui/components/ui/toaster";
11-
import { normalizeSubscriptionImportErrorInfo } from "@subboost/core/subscription/import-error";
12-
import type { ParsedNode } from "@subboost/core/types/node";
13-
import { DEFAULT_NODE_NAME_TEMPLATE, formatNodeNameFromTemplate } from "@subboost/core/node-name-template";
149
import { cn } from "@subboost/ui/lib/utils";
15-
import { getNodeSourceIds, useConfigStore, type SubscriptionSource, type SourceType } from "@subboost/ui/store/config-store";
16-
import { useUserStore } from "@subboost/ui/store/user-store";
10+
import type { SourceType } from "@subboost/ui/store/config-store";
1711
import { getSubscriptionUserInfoDisplay } from "@subboost/ui/product/subscription/subscription-userinfo-display";
18-
import { markSourceAsPendingImport } from "@subboost/ui/product/subscription/source-import-state";
19-
import { moveSubscriptionSource } from "@subboost/ui/product/subscription/source-order";
2012
import { buildSourceDisplayLabel } from "@subboost/ui/product/converter/source-display-label";
21-
import {
22-
useProductInteractionAdapter,
23-
type ProductInteractionResult,
24-
} from "@subboost/ui/product/interactions";
13+
import { useSubscriptionSourcesController } from "@subboost/ui/product/converter/use-subscription-sources-controller";
2514
import { sourceTypeInfo } from "../constants";
2615
import { SectionHeader } from "../section-header";
2716
import { SubscriptionImportErrorBadge } from "@subboost/ui/product/converter/subscription-import-error";
@@ -34,243 +23,25 @@ export function InputSection({
3423
isExpanded: boolean;
3524
onToggle: () => void;
3625
}) {
37-
const { nodes, parseErrors, sources, setSources, parseSingleSource } = useConfigStore();
38-
const { user } = useUserStore();
39-
const interactions = useProductInteractionAdapter();
40-
41-
const maxSourcesPerType = React.useMemo(() => {
42-
if (user?.isAdmin) return Number.POSITIVE_INFINITY;
43-
if (!user) return 2;
44-
const raw = user.quota?.maxImportSourcesPerType;
45-
return typeof raw === "number" && Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : 5;
46-
}, [user]);
47-
26+
const {
27+
addSource,
28+
closeExpandedSourceEditor,
29+
error: globalError,
30+
expandedSource,
31+
expandedSourcePreviewName,
32+
handleImportSource,
33+
moveSource,
34+
nodesBySourceId,
35+
removeSource,
36+
setExpandedSourceId,
37+
setShowAddMenu,
38+
showAddMenu,
39+
sources,
40+
updateSource,
41+
updateSourceMeta,
42+
updateSourceType,
43+
} = useSubscriptionSourcesController({ mode: "advanced" });
4844
const sourceCount = sources.length;
49-
const globalError = React.useMemo(
50-
() => normalizeSubscriptionImportErrorInfo(parseErrors[0] ?? null)?.message ?? null,
51-
[parseErrors]
52-
);
53-
const nodesBySourceId = React.useMemo(() => {
54-
const grouped = new Map<string, ParsedNode[]>();
55-
for (const node of nodes) {
56-
for (const sourceId of getNodeSourceIds(node)) {
57-
const current = grouped.get(sourceId);
58-
if (current) {
59-
current.push(node);
60-
} else {
61-
grouped.set(sourceId, [node]);
62-
}
63-
}
64-
}
65-
return grouped;
66-
}, [nodes]);
67-
68-
const [showAddMenu, setShowAddMenu] = React.useState(false);
69-
const [expandedSourceId, setExpandedSourceId] = React.useState<string | null>(null);
70-
const expandedSource = React.useMemo(
71-
() => sources.find((s) => s.id === expandedSourceId) ?? null,
72-
[expandedSourceId, sources]
73-
);
74-
const [expandedSourceSnapshot, setExpandedSourceSnapshot] = React.useState<{
75-
id: string;
76-
content: string;
77-
tag: string;
78-
nameTemplate: string;
79-
useProxyProviders: boolean;
80-
userinfoUrl: string;
81-
userinfoUserAgent: string;
82-
} | null>(null);
83-
84-
React.useEffect(() => {
85-
if (!expandedSource) {
86-
setExpandedSourceSnapshot(null);
87-
return;
88-
}
89-
setExpandedSourceSnapshot((prev) => {
90-
if (prev?.id === expandedSource.id) return prev;
91-
return {
92-
id: expandedSource.id,
93-
content: expandedSource.content,
94-
tag: (expandedSource.tag ?? "").trim(),
95-
nameTemplate: (expandedSource.nameTemplate ?? "").trim(),
96-
useProxyProviders: Boolean(expandedSource.useProxyProviders),
97-
userinfoUrl: (expandedSource.userinfoUrl ?? "").trim(),
98-
userinfoUserAgent: (expandedSource.userinfoUserAgent ?? "").trim(),
99-
};
100-
});
101-
}, [expandedSource]);
102-
103-
const expandedSourcePreviewName = React.useMemo(() => {
104-
if (!expandedSource) return "";
105-
return formatNodeNameFromTemplate({
106-
originName: "节点名称",
107-
tag: expandedSource.tag,
108-
template: expandedSource.nameTemplate,
109-
});
110-
}, [expandedSource]);
111-
112-
const closeExpandedSourceEditor = React.useCallback(() => {
113-
if (expandedSource && expandedSourceSnapshot?.id === expandedSource.id && !expandedSource.parsing) {
114-
const next = {
115-
content: expandedSource.content,
116-
tag: (expandedSource.tag ?? "").trim(),
117-
nameTemplate: (expandedSource.nameTemplate ?? "").trim(),
118-
useProxyProviders: Boolean(expandedSource.useProxyProviders),
119-
userinfoUrl: (expandedSource.userinfoUrl ?? "").trim(),
120-
userinfoUserAgent: (expandedSource.userinfoUserAgent ?? "").trim(),
121-
};
122-
const changed =
123-
next.content !== expandedSourceSnapshot.content ||
124-
next.tag !== expandedSourceSnapshot.tag ||
125-
next.nameTemplate !== expandedSourceSnapshot.nameTemplate ||
126-
next.useProxyProviders !== expandedSourceSnapshot.useProxyProviders ||
127-
next.userinfoUrl !== expandedSourceSnapshot.userinfoUrl ||
128-
next.userinfoUserAgent !== expandedSourceSnapshot.userinfoUserAgent;
129-
if (changed) void parseSingleSource(expandedSource.id);
130-
}
131-
setExpandedSourceId(null);
132-
}, [expandedSource, expandedSourceSnapshot, parseSingleSource]);
133-
134-
// Initialize with one source if empty
135-
React.useEffect(() => {
136-
if (sources.length === 0) {
137-
setSources([{ id: "1", type: "url", content: "", nameTemplate: DEFAULT_NODE_NAME_TEMPLATE }]);
138-
}
139-
}, [sources.length, setSources]);
140-
141-
const addSource = (type: SourceType = "url") => {
142-
const used = sources.filter((s) => s.type === type).length;
143-
if (used >= maxSourcesPerType) {
144-
toast({
145-
title: user
146-
? `每种导入方式最多 ${maxSourcesPerType} 个`
147-
: "未登录用户每种导入方式最多 2 个(登录后可提升)",
148-
variant: "warning",
149-
});
150-
setShowAddMenu(false);
151-
return;
152-
}
153-
const newSource: SubscriptionSource = {
154-
id: Date.now().toString(),
155-
type,
156-
content: "",
157-
nameTemplate: DEFAULT_NODE_NAME_TEMPLATE,
158-
};
159-
setSources([...sources, newSource]);
160-
interactions.sourceAdded?.({
161-
mode: "advanced",
162-
sourceType: type,
163-
sourceCount: sources.length + 1,
164-
});
165-
setShowAddMenu(false);
166-
};
167-
168-
const removeSource = (id: string) => {
169-
if (sources.length > 1) {
170-
setSources(sources.filter((s) => s.id !== id));
171-
}
172-
};
173-
174-
const updateSource = (id: string, content: string) => {
175-
setSources(
176-
sources.map((s) => {
177-
if (s.id !== id) return s;
178-
if (s.content === content) return s;
179-
return markSourceAsPendingImport({ ...s, content });
180-
})
181-
);
182-
};
183-
184-
const updateSourceMeta = (id: string, patch: Partial<SubscriptionSource>) => {
185-
setSources(
186-
sources.map((s) => {
187-
if (s.id !== id) return s;
188-
189-
const next = { ...s, ...patch };
190-
const changed = (Object.keys(patch) as Array<keyof SubscriptionSource>).some((key) => s[key] !== next[key]);
191-
if (!changed) return s;
192-
193-
const needsReimport =
194-
Object.prototype.hasOwnProperty.call(patch, "tag") ||
195-
Object.prototype.hasOwnProperty.call(patch, "nameTemplate") ||
196-
Object.prototype.hasOwnProperty.call(patch, "useProxyProviders") ||
197-
Object.prototype.hasOwnProperty.call(patch, "userinfoUrl") ||
198-
Object.prototype.hasOwnProperty.call(patch, "userinfoUserAgent");
199-
200-
return needsReimport ? markSourceAsPendingImport(next) : next;
201-
})
202-
);
203-
};
204-
205-
const moveSource = React.useCallback(
206-
(sourceId: string, direction: "up" | "down") => {
207-
const nextSources = moveSubscriptionSource(sources, sourceId, direction);
208-
if (nextSources === sources) return;
209-
setSources(nextSources);
210-
},
211-
[setSources, sources]
212-
);
213-
214-
const updateSourceType = (id: string, type: SourceType) => {
215-
const current = sources.find((s) => s.id === id);
216-
if (!current) return;
217-
if (current.type === type) return;
218-
219-
const used = sources.filter((s) => s.type === type).length;
220-
if (used >= maxSourcesPerType) {
221-
toast({
222-
title: user
223-
? `每种导入方式最多 ${maxSourcesPerType} 个`
224-
: "未登录用户每种导入方式最多 2 个(登录后可提升)",
225-
variant: "warning",
226-
});
227-
return;
228-
}
229-
230-
setSources(
231-
sources.map((s) => {
232-
if (s.id !== id) return s;
233-
234-
return markSourceAsPendingImport({
235-
...s,
236-
type,
237-
content: "",
238-
useProxyProviders: type === "url" ? Boolean(s.useProxyProviders) : undefined,
239-
userinfoUrl: type === "url" ? s.userinfoUrl : undefined,
240-
userinfoUserAgent: type === "url" ? s.userinfoUserAgent : undefined,
241-
lastParsedContent: undefined,
242-
lastParsedTag: undefined,
243-
lastParsedNameTemplate: undefined,
244-
});
245-
})
246-
);
247-
};
248-
249-
const handleImportSource = React.useCallback(
250-
async (sourceId: string) => {
251-
const source = sources.find((item) => item.id === sourceId);
252-
if (!source || !source.content.trim() || source.parsing) return;
253-
254-
await parseSingleSource(sourceId);
255-
256-
const latestState = useConfigStore.getState();
257-
const latestSource = latestState.sources.find((item) => item.id === sourceId) ?? source;
258-
const result: ProductInteractionResult = latestSource.parsed
259-
? "success"
260-
: latestSource.error || latestSource.errorInfo
261-
? "runtimeError"
262-
: "validationError";
263-
interactions.sourceImported?.({
264-
mode: "advanced",
265-
sourceType: latestSource.type,
266-
result,
267-
sourceCount: latestState.sources.filter((item) => item.content.trim()).length,
268-
nodeCount: latestSource.nodeCount ?? 0,
269-
usesProxyProvider: Boolean(latestSource.useProxyProviders),
270-
});
271-
},
272-
[interactions, parseSingleSource, sources],
273-
);
27445

27546
return (
27647
<div>

0 commit comments

Comments
 (0)