History

feat(web): integrate theme search with import

t3-code[bot] committed 3 months ago

Commit 784493f1 parent d11283b4 files · +264 −293Browse files

Unified · Split

4 files
  1. apps/web/src/components/settings/ThemeImportDialog.tsx +18 −1
  2. apps/web/src/components/settings/ThemeSearchDialog.tsx +0 −269
  3. apps/web/src/components/settings/ThemeSearchSection.tsx +246 −0
  4. apps/web/src/components/settings/ThemeSettings.tsx +0 −23
apps/web/src/components/settings/ThemeImportDialog.tsx+18−1
@@ -28,6 +28,7 @@ import { · 27 unmodified lines
28 DialogPopup,28 DialogPopup,
29 DialogTitle,29 DialogTitle,
30} from "../ui/dialog";30} from "../ui/dialog";
31import { ThemeSearchSection } from "./ThemeSearchSection";
31 32
32/**33/**
33 * A full theme export is a few KB, so anything past this is not a theme file.34 * A full theme export is a few KB, so anything past this is not a theme file.
@@ -426,7 +427,23 @@ export function ThemeImportDialog({ · 392 unmodified lines
426 <DialogHeader>427 <DialogHeader>
427 <DialogTitle>Add a theme</DialogTitle>428 <DialogTitle>Add a theme</DialogTitle>
428 </DialogHeader>429 </DialogHeader>
429 <DialogPanel className="space-y-4">430 <DialogPanel className="space-y-5">
431 <ThemeSearchSection
432 onInstalled={(themes) => {
433 onImportedMany(themes, { updated: false });
434 onOpenChange(false);
435 }}
436 open={open}
437 />
438
439 <div className="flex items-center gap-3" aria-hidden>
440 <div className="h-px flex-1 bg-border" />
441 <span className="text-muted-foreground text-[11px] uppercase tracking-wider">
442 or import a file
443 </span>
444 <div className="h-px flex-1 bg-border" />
445 </div>
446
430 {(() => {447 {(() => {
431 const dropHandlers = {448 const dropHandlers = {
432 onDragEnter: (event: DragEvent<HTMLDivElement>) => {449 onDragEnter: (event: DragEvent<HTMLDivElement>) => {
apps/web/src/components/settings/ThemeSearchDialog.tsx+0−269
@@ -1,269 +0,0 @@
1import { CheckCircle2Icon, PaletteIcon, PlusIcon, SearchIcon, ShieldCheckIcon } from "lucide-react";
2import type { FormEvent } from "react";
3import { useCallback, useEffect, useRef, useState } from "react";
4import {
5 importOpenVsxThemeExtension,
6 searchOpenVsxThemes,
7 type OpenVsxThemeExtension,
8} from "../../openVsxThemes";
9import {
10 getCustomThemes,
11 installCustomTheme,
12 removeCustomTheme,
13 type ThemeDefinition,
14} from "../../themePalette";
15import { Button } from "../ui/button";
16import {
17 Dialog,
18 DialogDescription,
19 DialogHeader,
20 DialogPanel,
21 DialogPopup,
22 DialogTitle,
23} from "../ui/dialog";
24import { Input } from "../ui/input";
25import { Spinner } from "../ui/spinner";
26
27const DOWNLOAD_FORMAT = new Intl.NumberFormat(undefined, {
28 notation: "compact",
29 maximumFractionDigits: 1,
30});
31const SUGGESTED_SEARCHES = ["Dracula", "Catppuccin", "Nord", "Tokyo Night"];
32
33export function ThemeSearchDialog({
34 open,
35 onOpenChange,
36 onInstalled,
37}: {
38 open: boolean;
39 onOpenChange: (open: boolean) => void;
40 onInstalled: (themes: ReadonlyArray<ThemeDefinition>) => void;
41}) {
42 const [query, setQuery] = useState("");
43 const [results, setResults] = useState<ReadonlyArray<OpenVsxThemeExtension> | null>(null);
44 const [error, setError] = useState<string | null>(null);
45 const [isSearching, setIsSearching] = useState(false);
46 const [installingId, setInstallingId] = useState<string | null>(null);
47 const requestRef = useRef<AbortController | null>(null);
48
49 useEffect(() => {
50 requestRef.current?.abort();
51 requestRef.current = null;
52 if (!open) return;
53 setQuery("");
54 setResults(null);
55 setError(null);
56 setIsSearching(false);
57 setInstallingId(null);
58 }, [open]);
59
60 const runSearch = useCallback(async (searchText: string) => {
61 const trimmed = searchText.trim();
62 if (!trimmed) return;
63 requestRef.current?.abort();
64 const controller = new AbortController();
65 requestRef.current = controller;
66 setQuery(trimmed);
67 setIsSearching(true);
68 setError(null);
69 setResults(null);
70 try {
71 const nextResults = await searchOpenVsxThemes(trimmed, controller.signal);
72 if (!controller.signal.aborted) setResults(nextResults);
73 } catch (cause) {
74 if (!controller.signal.aborted) {
75 setError(cause instanceof Error ? cause.message : "Open VSX search failed.");
76 }
77 } finally {
78 if (requestRef.current === controller) {
79 requestRef.current = null;
80 setIsSearching(false);
81 }
82 }
83 }, []);
84
85 const handleSearch = useCallback(
86 (event: FormEvent<HTMLFormElement>) => {
87 event.preventDefault();
88 void runSearch(query);
89 },
90 [query, runSearch],
91 );
92
93 const handleInstall = useCallback(
94 async (extension: OpenVsxThemeExtension) => {
95 requestRef.current?.abort();
96 const controller = new AbortController();
97 requestRef.current = controller;
98 setInstallingId(extension.id);
99 setError(null);
100 try {
101 const themes = await importOpenVsxThemeExtension(extension, controller.signal);
102 if (controller.signal.aborted) return;
103 const existingIds = new Set(getCustomThemes().map((theme) => theme.id));
104 const duplicates = themes.filter((theme) => existingIds.has(theme.id));
105 if (duplicates.length > 0) {
106 throw new Error(`${duplicates.map((theme) => theme.label).join(", ")} is already added.`);
107 }
108
109 const installed: ThemeDefinition[] = [];
110 try {
111 for (const theme of themes) installed.push(installCustomTheme(theme));
112 } catch (cause) {
113 for (const theme of installed) {
114 try {
115 removeCustomTheme(theme.id);
116 } catch {
117 // Best effort rollback. The original storage failure is clearer.
118 }
119 }
120 throw cause;
121 }
122 onInstalled(installed);
123 onOpenChange(false);
124 } catch (cause) {
125 if (!controller.signal.aborted) {
126 setError(cause instanceof Error ? cause.message : "That theme could not be added.");
127 }
128 } finally {
129 if (requestRef.current === controller) {
130 requestRef.current = null;
131 setInstallingId(null);
132 }
133 }
134 },
135 [onInstalled, onOpenChange],
136 );
137
138 return (
139 <Dialog
140 open={open}
141 onOpenChange={(nextOpen) => {
142 if (!nextOpen) requestRef.current?.abort();
143 onOpenChange(nextOpen);
144 }}
145 >
146 <DialogPopup className="max-w-2xl overflow-hidden">
147 <DialogHeader>
148 <DialogTitle>Search themes</DialogTitle>
149 <DialogDescription>
150 Find community themes from Open VSX and add them in one click.
151 </DialogDescription>
152 </DialogHeader>
153 <DialogPanel className="space-y-4">
154 <form className="relative flex gap-2" onSubmit={handleSearch}>
155 <SearchIcon className="pointer-events-none absolute left-3 top-1/2 z-10 size-4 -translate-y-1/2 text-muted-foreground" />
156 <Input
157 aria-label="Search Open VSX themes"
158 className="rounded-xl [&_input]:pl-9"
159 autoFocus
160 onChange={(event) => setQuery(event.currentTarget.value)}
161 placeholder="try dracula, nord, catppuccin..."
162 size="lg"
163 type="search"
164 value={query}
165 />
166 <Button disabled={!query.trim() || isSearching || installingId !== null} type="submit">
167 {isSearching ? <Spinner /> : <SearchIcon />}
168 Search
169 </Button>
170 </form>
171
172 {error ? (
173 <div
174 aria-live="polite"
175 className="rounded-xl border border-destructive/30 bg-destructive/5 px-3 py-2 text-destructive text-sm"
176 >
177 {error}
178 </div>
179 ) : null}
180
181 {results === null && !isSearching ? (
182 <div className="flex min-h-48 flex-col items-center justify-center gap-4 rounded-2xl border border-dashed border-border/70 bg-muted/15 px-6 text-center">
183 <div>
184 <p className="text-sm font-medium">What theme are you looking for?</p>
185 <p className="mt-1 text-muted-foreground text-xs">
186 Only themes with a supported open-source license are shown.
187 </p>
188 </div>
189 <div className="flex flex-wrap justify-center gap-2">
190 {SUGGESTED_SEARCHES.map((suggestion) => (
191 <Button
192 key={suggestion}
193 size="xs"
194 variant="outline"
195 onClick={() => void runSearch(suggestion)}
196 >
197 {suggestion}
198 </Button>
199 ))}
200 </div>
201 </div>
202 ) : null}
203
204 {isSearching ? (
205 <div className="flex min-h-48 items-center justify-center gap-2 text-muted-foreground text-sm">
206 <Spinner /> Finding themes...
207 </div>
208 ) : null}
209
210 {results ? (
211 results.length === 0 ? (
212 <div className="flex min-h-40 flex-col items-center justify-center rounded-2xl border border-dashed text-center">
213 <p className="text-sm font-medium">No supported open-source themes found</p>
214 <p className="mt-1 text-muted-foreground text-xs">Try a broader search.</p>
215 </div>
216 ) : (
217 <div className="grid gap-2 sm:grid-cols-2">
218 {results.map((extension) => {
219 const isInstalling = installingId === extension.id;
220 return (
221 <article
222 className="group flex min-w-0 flex-col gap-3 rounded-xl border border-border/70 bg-card/60 p-3 transition-colors hover:bg-accent/20"
223 key={extension.id}
224 >
225 <div className="flex min-w-0 gap-3">
226 <div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
227 <PaletteIcon className="size-4" />
228 </div>
229 <div className="min-w-0 flex-1">
230 <h4 className="truncate text-sm font-medium">{extension.name}</h4>
231 <p className="truncate text-muted-foreground text-xs">
232 {extension.publisher} ·{" "}
233 {DOWNLOAD_FORMAT.format(extension.downloadCount)} downloads
234 </p>
235 </div>
236 </div>
237 <p className="line-clamp-2 min-h-8 text-muted-foreground text-xs leading-4">
238 {extension.description || "A community color theme for your editor."}
239 </p>
240 <div className="mt-auto flex items-center justify-between gap-2">
241 <span className="inline-flex items-center gap-1 text-muted-foreground text-[11px]">
242 <ShieldCheckIcon className="size-3" /> {extension.license}
243 </span>
244 <Button
245 disabled={installingId !== null}
246 size="xs"
247 variant="outline"
248 onClick={() => void handleInstall(extension)}
249 >
250 {isInstalling ? <Spinner /> : <PlusIcon />}
251 {isInstalling ? "Adding..." : "Add"}
252 </Button>
253 </div>
254 </article>
255 );
256 })}
257 </div>
258 )
259 ) : null}
260
261 <div className="flex items-center justify-center gap-1.5 text-muted-foreground text-[11px]">
262 <CheckCircle2Icon className="size-3" /> Sourced from Open VSX, with license and package
263 integrity checks
264 </div>
265 </DialogPanel>
266 </DialogPopup>
267 </Dialog>
268 );
269}
apps/web/src/components/settings/ThemeSearchSection.tsx+246−0
@@ -0,0 +1,246 @@
1import { CheckCircle2Icon, PaletteIcon, PlusIcon, SearchIcon, ShieldCheckIcon } from "lucide-react";
2import type { FormEvent } from "react";
3import { useCallback, useEffect, useRef, useState } from "react";
4import {
5 importOpenVsxThemeExtension,
6 searchOpenVsxThemes,
7 type OpenVsxThemeExtension,
8} from "../../openVsxThemes";
9import {
10 getCustomThemes,
11 installCustomTheme,
12 removeCustomTheme,
13 type ThemeDefinition,
14} from "../../themePalette";
15import { Button } from "../ui/button";
16import { Input } from "../ui/input";
17import { Spinner } from "../ui/spinner";
18
19const DOWNLOAD_FORMAT = new Intl.NumberFormat(undefined, {
20 notation: "compact",
21 maximumFractionDigits: 1,
22});
23const SUGGESTED_SEARCHES = ["Dracula", "Catppuccin", "Nord", "Tokyo Night"];
24
25export function ThemeSearchSection({
26 open,
27 onInstalled,
28}: {
29 open: boolean;
30 onInstalled: (themes: ReadonlyArray<ThemeDefinition>) => void;
31}) {
32 const [query, setQuery] = useState("");
33 const [results, setResults] = useState<ReadonlyArray<OpenVsxThemeExtension> | null>(null);
34 const [error, setError] = useState<string | null>(null);
35 const [isSearching, setIsSearching] = useState(false);
36 const [installingId, setInstallingId] = useState<string | null>(null);
37 const requestRef = useRef<AbortController | null>(null);
38
39 useEffect(() => {
40 requestRef.current?.abort();
41 requestRef.current = null;
42 if (!open) return;
43 setQuery("");
44 setResults(null);
45 setError(null);
46 setIsSearching(false);
47 setInstallingId(null);
48 }, [open]);
49
50 const runSearch = useCallback(async (searchText: string) => {
51 const trimmed = searchText.trim();
52 if (!trimmed) return;
53 requestRef.current?.abort();
54 const controller = new AbortController();
55 requestRef.current = controller;
56 setQuery(trimmed);
57 setIsSearching(true);
58 setError(null);
59 setResults(null);
60 try {
61 const nextResults = await searchOpenVsxThemes(trimmed, controller.signal);
62 if (!controller.signal.aborted) setResults(nextResults);
63 } catch (cause) {
64 if (!controller.signal.aborted) {
65 setError(cause instanceof Error ? cause.message : "Open VSX search failed.");
66 }
67 } finally {
68 if (requestRef.current === controller) {
69 requestRef.current = null;
70 setIsSearching(false);
71 }
72 }
73 }, []);
74
75 const handleSearch = useCallback(
76 (event: FormEvent<HTMLFormElement>) => {
77 event.preventDefault();
78 void runSearch(query);
79 },
80 [query, runSearch],
81 );
82
83 const handleInstall = useCallback(
84 async (extension: OpenVsxThemeExtension) => {
85 requestRef.current?.abort();
86 const controller = new AbortController();
87 requestRef.current = controller;
88 setInstallingId(extension.id);
89 setError(null);
90 try {
91 const themes = await importOpenVsxThemeExtension(extension, controller.signal);
92 if (controller.signal.aborted) return;
93 const existingIds = new Set(getCustomThemes().map((theme) => theme.id));
94 const duplicates = themes.filter((theme) => existingIds.has(theme.id));
95 if (duplicates.length > 0) {
96 throw new Error(`${duplicates.map((theme) => theme.label).join(", ")} is already added.`);
97 }
98
99 const installed: ThemeDefinition[] = [];
100 try {
101 for (const theme of themes) installed.push(installCustomTheme(theme));
102 } catch (cause) {
103 for (const theme of installed) {
104 try {
105 removeCustomTheme(theme.id);
106 } catch {
107 // Best effort rollback. The original storage failure is clearer.
108 }
109 }
110 throw cause;
111 }
112 onInstalled(installed);
113 } catch (cause) {
114 if (!controller.signal.aborted) {
115 setError(cause instanceof Error ? cause.message : "That theme could not be added.");
116 }
117 } finally {
118 if (requestRef.current === controller) {
119 requestRef.current = null;
120 setInstallingId(null);
121 }
122 }
123 },
124 [onInstalled],
125 );
126
127 return (
128 <section className="space-y-3" aria-labelledby="theme-search-heading">
129 <div>
130 <h3 className="text-sm font-medium" id="theme-search-heading">
131 Search community themes
132 </h3>
133 <p className="mt-0.5 text-muted-foreground text-xs">
134 Find open-source themes from Open VSX.
135 </p>
136 </div>
137 <form className="relative flex gap-2" onSubmit={handleSearch}>
138 <SearchIcon className="pointer-events-none absolute left-3 top-1/2 z-10 size-4 -translate-y-1/2 text-muted-foreground" />
139 <Input
140 aria-label="Search Open VSX themes"
141 className="rounded-xl [&_input]:pl-9"
142 autoFocus
143 onChange={(event) => setQuery(event.currentTarget.value)}
144 placeholder="try dracula, nord, catppuccin..."
145 size="lg"
146 type="search"
147 value={query}
148 />
149 <Button
150 disabled={!query.trim() || isSearching || installingId !== null}
151 type="submit"
152 variant="outline"
153 >
154 {isSearching ? <Spinner /> : <SearchIcon />}
155 Search
156 </Button>
157 </form>
158
159 {error ? (
160 <div
161 aria-live="polite"
162 className="rounded-xl border border-destructive/30 bg-destructive/5 px-3 py-2 text-destructive text-sm"
163 >
164 {error}
165 </div>
166 ) : null}
167
168 {results === null && !isSearching ? (
169 <div className="flex flex-wrap items-center gap-1.5">
170 <span className="mr-1 text-muted-foreground text-xs">Popular</span>
171 {SUGGESTED_SEARCHES.map((suggestion) => (
172 <Button
173 key={suggestion}
174 size="xs"
175 variant="ghost"
176 onClick={() => void runSearch(suggestion)}
177 >
178 {suggestion}
179 </Button>
180 ))}
181 </div>
182 ) : null}
183
184 {isSearching ? (
185 <div className="flex min-h-20 items-center justify-center gap-2 text-muted-foreground text-sm">
186 <Spinner /> Finding themes...
187 </div>
188 ) : null}
189
190 {results ? (
191 results.length === 0 ? (
192 <div className="flex min-h-40 flex-col items-center justify-center rounded-2xl border border-dashed text-center">
193 <p className="text-sm font-medium">No supported open-source themes found</p>
194 <p className="mt-1 text-muted-foreground text-xs">Try a broader search.</p>
195 </div>
196 ) : (
197 <div className="grid gap-2 sm:grid-cols-2">
198 {results.map((extension) => {
199 const isInstalling = installingId === extension.id;
200 return (
201 <article
202 className="group flex min-w-0 flex-col gap-3 rounded-xl border border-border/70 bg-card/60 p-3 transition-colors hover:bg-accent/20"
203 key={extension.id}
204 >
205 <div className="flex min-w-0 gap-3">
206 <div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
207 <PaletteIcon className="size-4" />
208 </div>
209 <div className="min-w-0 flex-1">
210 <h4 className="truncate text-sm font-medium">{extension.name}</h4>
211 <p className="truncate text-muted-foreground text-xs">
212 {extension.publisher} · {DOWNLOAD_FORMAT.format(extension.downloadCount)}{" "}
213 downloads
214 </p>
215 </div>
216 </div>
217 <p className="line-clamp-2 min-h-8 text-muted-foreground text-xs leading-4">
218 {extension.description || "A community color theme for your editor."}
219 </p>
220 <div className="mt-auto flex items-center justify-between gap-2">
221 <span className="inline-flex items-center gap-1 text-muted-foreground text-[11px]">
222 <ShieldCheckIcon className="size-3" /> {extension.license}
223 </span>
224 <Button
225 disabled={installingId !== null}
226 size="xs"
227 variant="outline"
228 onClick={() => void handleInstall(extension)}
229 >
230 {isInstalling ? <Spinner /> : <PlusIcon />}
231 {isInstalling ? "Adding..." : "Add"}
232 </Button>
233 </div>
234 </article>
235 );
236 })}
237 </div>
238 )
239 ) : null}
240
241 <div className="flex items-center gap-1.5 text-muted-foreground text-[11px]">
242 <CheckCircle2Icon className="size-3" /> Licensed themes only, with package integrity checks
243 </div>
244 </section>
245 );
246}
apps/web/src/components/settings/ThemeSettings.tsx+0−23
@@ -3,7 +3,6 @@ import { · 2 unmodified lines
3 DownloadIcon,3 DownloadIcon,
4 PenLineIcon,4 PenLineIcon,
5 PlusIcon,5 PlusIcon,
6 SearchIcon,
7 Trash2Icon,6 Trash2Icon,
8 UploadIcon,7 UploadIcon,
9} from "lucide-react";8} from "lucide-react";
@@ -36,7 +35,6 @@ import { Button } from "../ui/button"; · 26 unmodified lines
36import { stackedThreadToast, toastManager } from "../ui/toast";35import { stackedThreadToast, toastManager } from "../ui/toast";
37import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip";36import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
38import { ThemeImportDialog } from "./ThemeImportDialog";37import { ThemeImportDialog } from "./ThemeImportDialog";
39import { ThemeSearchDialog } from "./ThemeSearchDialog";
40import { useThemeEditorStore } from "./themeEditorStore";38import { useThemeEditorStore } from "./themeEditorStore";
41import {39import {
42 STANDARD_THEME_CARDS,40 STANDARD_THEME_CARDS,
@@ -252,7 +250,6 @@ export function ThemeLibrary({ · 209 unmodified lines
252 setThemeHalf: (appearance: ThemeAppearance, themeId: string | null) => boolean;250 setThemeHalf: (appearance: ThemeAppearance, themeId: string | null) => boolean;
253}) {251}) {
254 const openThemeEditor = useThemeEditorStore((store) => store.openThemeEditor);252 const openThemeEditor = useThemeEditorStore((store) => store.openThemeEditor);
255 const [isSearchOpen, setIsSearchOpen] = useState(false);
256 const [themeToRemove, setThemeToRemove] = useState<ThemeDefinition | null>(null);253 const [themeToRemove, setThemeToRemove] = useState<ThemeDefinition | null>(null);
257 // Keep the last removal target so the dialog title stays populated while the254 // Keep the last removal target so the dialog title stays populated while the
258 // close animation plays after confirming.255 // close animation plays after confirming.
@@ -557,10 +554,6 @@ export function ThemeLibrary({ · 298 unmodified lines
557 <div className="flex min-h-8 flex-wrap items-center justify-between gap-3 px-3 pt-2 sm:px-4">554 <div className="flex min-h-8 flex-wrap items-center justify-between gap-3 px-3 pt-2 sm:px-4">
558 <h3 className="text-sm font-medium tracking-[-0.005em] text-foreground">Themes</h3>555 <h3 className="text-sm font-medium tracking-[-0.005em] text-foreground">Themes</h3>
559 <div className="flex flex-wrap items-center justify-end gap-2">556 <div className="flex flex-wrap items-center justify-end gap-2">
560 <Button size="xs" variant="outline" onClick={() => setIsSearchOpen(true)}>
561 <SearchIcon />
562 Search themes
563 </Button>
564 <Button557 <Button
565 size="xs"558 size="xs"
566 variant="ghost"559 variant="ghost"
@@ -583,22 +576,6 @@ export function ThemeLibrary({ · 16 unmodified lines
583 </div>576 </div>
584 </div>577 </div>
585 {renderPairGrid()}578 {renderPairGrid()}
586 <ThemeSearchDialog
587 onInstalled={(installedThemes) => {
588 toastManager.add(
589 stackedThreadToast({
590 type: "success",
591 title:
592 installedThemes.length === 1
593 ? `${installedThemes[0]!.label} added`
594 : `${installedThemes.length} themes added`,
595 description: installedThemes.map((installed) => installed.label).join(", "),
596 }),
597 );
598 }}
599 onOpenChange={setIsSearchOpen}
600 open={isSearchOpen}
601 />
602 <ThemeImportDialog579 <ThemeImportDialog
603 onImportedMany={(importedThemes, { updated }) => {580 onImportedMany={(importedThemes, { updated }) => {
604 // An updated theme that is showing (as the base or either half)581 // An updated theme that is showing (as the base or either half)