Skip to content

Commit 3bd036e

Browse files
dingyi222666claude
andcommitted
feat(preset): add UploadGithubPresetDialog for local file upload
- Implement UploadGithubPresetDialog to support dragging and selecting local .yml/.yaml files for upload - Replace static GitHub link in app.tsx with the new upload dialog trigger - Add buildPresetModel utility for converting raw presets to the required model format 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 50fc463 commit 3bd036e

2 files changed

Lines changed: 331 additions & 7 deletions

File tree

Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
"use client";
2+
3+
import { useEffect, useMemo, useRef, useState } from "react";
4+
import { Button } from "@/components/ui/button";
5+
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
6+
import { Input } from "@/components/ui/input";
7+
import { Label } from "@/components/ui/label";
8+
import { useToast } from "@/hooks/use-toast";
9+
import {
10+
getPresetDefaultFileName,
11+
getPresetDisplayName,
12+
getPresetUploadToken,
13+
PresetModel,
14+
uploadPreset,
15+
} from "@/hooks/use-preset";
16+
import { cn } from "@/lib/utils";
17+
import { isCharacterPresetTemplate, isRawPreset, RawPreset, CharacterPresetTemplate } from "@/types/preset";
18+
import { load } from "js-yaml";
19+
20+
interface UploadGithubPresetDialogProps {
21+
open: boolean;
22+
onOpenChange: (open: boolean) => void;
23+
}
24+
25+
const ALLOWED_EXTENSIONS = [".yml", ".yaml"];
26+
27+
function isAllowedFile(file: File) {
28+
const lowerName = file.name.toLowerCase();
29+
return ALLOWED_EXTENSIONS.some((ext) => lowerName.endsWith(ext));
30+
}
31+
32+
function buildPresetModel(preset: RawPreset | CharacterPresetTemplate): PresetModel {
33+
if (isRawPreset(preset)) {
34+
const name = preset.keywords?.[0] ?? "preset";
35+
return {
36+
id: crypto.randomUUID(),
37+
name,
38+
type: "main",
39+
lastModified: Date.now(),
40+
preset,
41+
};
42+
}
43+
44+
if (isCharacterPresetTemplate(preset)) {
45+
const name = preset.name ?? "preset";
46+
return {
47+
id: crypto.randomUUID(),
48+
name,
49+
type: "character",
50+
lastModified: Date.now(),
51+
preset,
52+
};
53+
}
54+
55+
throw new Error("Invalid preset");
56+
}
57+
58+
export function UploadGithubPresetDialog({
59+
open,
60+
onOpenChange,
61+
}: UploadGithubPresetDialogProps) {
62+
const { toast } = useToast();
63+
const fileInputRef = useRef<HTMLInputElement>(null);
64+
const [isDragging, setIsDragging] = useState(false);
65+
const [isUploading, setIsUploading] = useState(false);
66+
const [preset, setPreset] = useState<PresetModel | null>(null);
67+
const [fileName, setFileName] = useState("");
68+
const [successUrl, setSuccessUrl] = useState("");
69+
const [successOpen, setSuccessOpen] = useState(false);
70+
71+
const presetName = useMemo(() => (preset ? getPresetDisplayName(preset) : ""), [preset]);
72+
73+
useEffect(() => {
74+
if (!open) {
75+
return;
76+
}
77+
setPreset(null);
78+
setFileName("");
79+
setSuccessOpen(false);
80+
setSuccessUrl("");
81+
setIsDragging(false);
82+
}, [open]);
83+
84+
useEffect(() => {
85+
if (preset) {
86+
setFileName(getPresetDefaultFileName(presetName));
87+
}
88+
}, [preset, presetName]);
89+
90+
const parsePresetContent = (content: string) => {
91+
const raw = load(content) as RawPreset | CharacterPresetTemplate;
92+
if (!isRawPreset(raw) && !isCharacterPresetTemplate(raw)) {
93+
throw new Error("Invalid preset");
94+
}
95+
return raw;
96+
};
97+
98+
const handleFile = async (file: File) => {
99+
if (!isAllowedFile(file)) {
100+
toast({
101+
title: "上传失败",
102+
description: "仅支持 .yml 或 .yaml 文件",
103+
variant: "destructive",
104+
});
105+
return;
106+
}
107+
108+
try {
109+
const content = await file.text();
110+
const rawPreset = parsePresetContent(content);
111+
const model = buildPresetModel(rawPreset);
112+
setPreset(model);
113+
} catch (error) {
114+
toast({
115+
title: "上传失败",
116+
description: "文件格式不正确,无法解析预设",
117+
variant: "destructive",
118+
});
119+
console.error(error);
120+
}
121+
};
122+
123+
const handleFiles = async (files: FileList | null) => {
124+
if (!files || files.length === 0) {
125+
return;
126+
}
127+
128+
if (files.length > 1) {
129+
toast({
130+
title: "上传失败",
131+
description: "一次只能上传一个文件",
132+
variant: "destructive",
133+
});
134+
return;
135+
}
136+
137+
await handleFile(files[0]);
138+
if (fileInputRef.current) {
139+
fileInputRef.current.value = "";
140+
}
141+
};
142+
143+
const handleUpload = async () => {
144+
if (!preset) {
145+
return;
146+
}
147+
setIsUploading(true);
148+
try {
149+
const result = await uploadPreset(preset, {
150+
token: getPresetUploadToken(),
151+
fileName,
152+
});
153+
154+
toast({
155+
title: "上传成功",
156+
description: `已创建 PR:${result.path}`,
157+
});
158+
setSuccessUrl(result.pull_request_url);
159+
setSuccessOpen(true);
160+
onOpenChange(false);
161+
} catch (error) {
162+
toast({
163+
title: "上传失败",
164+
description:
165+
error instanceof Error ? error.message : "上传失败",
166+
variant: "destructive",
167+
});
168+
} finally {
169+
setIsUploading(false);
170+
}
171+
};
172+
173+
return (
174+
<>
175+
<Dialog open={open} onOpenChange={onOpenChange}>
176+
<DialogContent className="max-w-[90vw] sm:max-w-[520px] rounded-2xl">
177+
<DialogHeader>
178+
<DialogTitle>上传预设</DialogTitle>
179+
<DialogDescription>
180+
选择本地预设文件后将自动创建 Pull Request 到 GitHub 仓库。
181+
</DialogDescription>
182+
</DialogHeader>
183+
184+
{!preset && (
185+
<div
186+
className={cn(
187+
"flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-6 text-center transition-colors",
188+
isDragging ? "border-primary bg-primary/5" : "border-muted-foreground/30"
189+
)}
190+
onDragEnter={(event) => {
191+
event.preventDefault();
192+
setIsDragging(true);
193+
}}
194+
onDragOver={(event) => {
195+
event.preventDefault();
196+
setIsDragging(true);
197+
}}
198+
onDragLeave={(event) => {
199+
event.preventDefault();
200+
setIsDragging(false);
201+
}}
202+
onDrop={(event) => {
203+
event.preventDefault();
204+
setIsDragging(false);
205+
void handleFiles(event.dataTransfer?.files ?? null);
206+
}}
207+
>
208+
<p className="text-sm text-muted-foreground">
209+
将 .yml/.yaml 文件拖到这里
210+
</p>
211+
<Button
212+
type="button"
213+
variant="secondary"
214+
onClick={() => fileInputRef.current?.click()}
215+
disabled={isUploading}
216+
>
217+
选择文件
218+
</Button>
219+
<input
220+
ref={fileInputRef}
221+
type="file"
222+
accept=".yaml,.yml"
223+
className="hidden"
224+
onChange={(event) => void handleFiles(event.target.files)}
225+
/>
226+
</div>
227+
)}
228+
229+
{preset && (
230+
<div className="grid gap-4">
231+
<div className="grid gap-2">
232+
<Label>预设名称</Label>
233+
<Input value={presetName} readOnly />
234+
</div>
235+
<div className="grid gap-2">
236+
<Label>预设类型</Label>
237+
<Input
238+
value={preset.type === "main" ? "主插件预设" : "伪装预设"}
239+
readOnly
240+
/>
241+
</div>
242+
<div className="grid gap-2">
243+
<Label>文件名</Label>
244+
<Input
245+
value={fileName}
246+
onChange={(event) => setFileName(event.target.value)}
247+
placeholder="preset-name.yml"
248+
/>
249+
<p className="text-xs text-muted-foreground">
250+
仅支持字母、数字、下划线、点、短横线和 yml/yaml 后缀。
251+
</p>
252+
</div>
253+
<Button
254+
type="button"
255+
variant="secondary"
256+
onClick={() => setPreset(null)}
257+
disabled={isUploading}
258+
>
259+
重新选择文件
260+
</Button>
261+
</div>
262+
)}
263+
264+
<DialogFooter>
265+
<Button
266+
type="button"
267+
variant="secondary"
268+
onClick={() => onOpenChange(false)}
269+
disabled={isUploading}
270+
>
271+
取消
272+
</Button>
273+
<Button
274+
type="button"
275+
onClick={handleUpload}
276+
disabled={!preset || isUploading}
277+
>
278+
{isUploading ? "上传中..." : "开始上传"}
279+
</Button>
280+
</DialogFooter>
281+
</DialogContent>
282+
</Dialog>
283+
284+
<Dialog open={successOpen} onOpenChange={setSuccessOpen}>
285+
<DialogContent>
286+
<DialogHeader>
287+
<DialogTitle>上传成功</DialogTitle>
288+
<DialogDescription>
289+
已创建 Pull Request,请前往查看。
290+
</DialogDescription>
291+
</DialogHeader>
292+
<div className="grid gap-2">
293+
<Label>Pull Request 地址</Label>
294+
<Input value={successUrl} readOnly />
295+
</div>
296+
<DialogFooter>
297+
<Button
298+
type="button"
299+
variant="secondary"
300+
onClick={() => {
301+
if (successUrl) {
302+
navigator.clipboard?.writeText(successUrl);
303+
}
304+
}}
305+
>
306+
复制链接
307+
</Button>
308+
<Button
309+
type="button"
310+
onClick={() => {
311+
if (successUrl) {
312+
window.open(successUrl, "_blank", "noopener");
313+
}
314+
}}
315+
>
316+
打开
317+
</Button>
318+
</DialogFooter>
319+
</DialogContent>
320+
</Dialog>
321+
</>
322+
);
323+
}

src/pages/app.tsx

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,16 @@ import { importPreset, usePresets } from "@/hooks/use-preset";
88
import { NewPresetDialog } from "@/components/new-preset-dialog";
99
import { useRef, useState } from "react";
1010
import { Input } from "@/components/ui/input";
11-
import { useToast } from "@/hooks/use-toast";
1211
import { Button } from "@/components/ui/button";
1312
import { Import, Upload } from "lucide-react";
13+
import { UploadGithubPresetDialog } from "@/components/upload-github-preset-dialog";
14+
import { useToast } from "@/hooks/use-toast";
1415

1516
export default function Page() {
1617
const presets = usePresets();
1718
const { toast } = useToast();
1819
const [searchQuery, setSearchQuery] = useState("");
19-
20+
const [uploadOpen, setUploadOpen] = useState(false);
2021
const fileInputRef = useRef<HTMLInputElement>(null);
2122

2223
const handleImportData = (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -54,17 +55,16 @@ export default function Page() {
5455
autoComplete="off"
5556
/>
5657
<div className="flex gap-2">
57-
<a target="_blank" rel="noopener noreferrer"
58-
href="https://github.com/ChatLunaLab/awesome-chatluna-presets/new/main/presets/chatluna">
59-
<Button variant="secondary" >
58+
<Button
59+
variant="secondary"
60+
onClick={() => setUploadOpen(true)}
61+
>
6062
<Upload className="h-4 w-4" />
6163
<span className="hidden md:inline">上传预设</span>
6264
</Button>
63-
</a>
6465
<Button
6566
variant="secondary"
6667
onClick={() => fileInputRef.current?.click()}
67-
6868
>
6969
<Import className="h-4 w-4 md:mr-0" />
7070
<span className="hidden md:inline">
@@ -84,6 +84,7 @@ export default function Page() {
8484
</div>
8585
<CharacterList presets={presets} searchQuery={searchQuery} />
8686
</div>
87+
<UploadGithubPresetDialog open={uploadOpen} onOpenChange={setUploadOpen} />
8788
</MainLayout>
8889
);
8990
}

0 commit comments

Comments
 (0)