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
2828 DialogPopup,
2929 DialogTitle,
3030 } from "../ui/dialog";
31+import { ThemeSearchSection } from "./ThemeSearchSection";
3132
3233 /**
3334 * 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
426427 <DialogHeader>
427428 <DialogTitle>Add a theme</DialogTitle>
428429 </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+
430447 {(() => {
431448 const dropHandlers = {
432449 onDragEnter: (event: DragEvent<HTMLDivElement>) => {
apps/web/src/components/settings/ThemeSearchDialog.tsx+0−269
@@ -1,269 +0,0 @@
1−import { CheckCircle2Icon, PaletteIcon, PlusIcon, SearchIcon, ShieldCheckIcon } from "lucide-react";
2−import type { FormEvent } from "react";
3−import { useCallback, useEffect, useRef, useState } from "react";
4−import {
5− importOpenVsxThemeExtension,
6− searchOpenVsxThemes,
7− type OpenVsxThemeExtension,
8−} from "../../openVsxThemes";
9−import {
10− getCustomThemes,
11− installCustomTheme,
12− removeCustomTheme,
13− type ThemeDefinition,
14−} from "../../themePalette";
15−import { Button } from "../ui/button";
16−import {
17− Dialog,
18− DialogDescription,
19− DialogHeader,
20− DialogPanel,
21− DialogPopup,
22− DialogTitle,
23−} from "../ui/dialog";
24−import { Input } from "../ui/input";
25−import { Spinner } from "../ui/spinner";
26−
27−const DOWNLOAD_FORMAT = new Intl.NumberFormat(undefined, {
28− notation: "compact",
29− maximumFractionDigits: 1,
30−});
31−const SUGGESTED_SEARCHES = ["Dracula", "Catppuccin", "Nord", "Tokyo Night"];
32−
33−export 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 @@
1+import { CheckCircle2Icon, PaletteIcon, PlusIcon, SearchIcon, ShieldCheckIcon } from "lucide-react";
2+import type { FormEvent } from "react";
3+import { useCallback, useEffect, useRef, useState } from "react";
4+import {
5+ importOpenVsxThemeExtension,
6+ searchOpenVsxThemes,
7+ type OpenVsxThemeExtension,
8+} from "../../openVsxThemes";
9+import {
10+ getCustomThemes,
11+ installCustomTheme,
12+ removeCustomTheme,
13+ type ThemeDefinition,
14+} from "../../themePalette";
15+import { Button } from "../ui/button";
16+import { Input } from "../ui/input";
17+import { Spinner } from "../ui/spinner";
18+
19+const DOWNLOAD_FORMAT = new Intl.NumberFormat(undefined, {
20+ notation: "compact",
21+ maximumFractionDigits: 1,
22+});
23+const SUGGESTED_SEARCHES = ["Dracula", "Catppuccin", "Nord", "Tokyo Night"];
24+
25+export 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
33 DownloadIcon,
44 PenLineIcon,
55 PlusIcon,
6− SearchIcon,
76 Trash2Icon,
87 UploadIcon,
98 } from "lucide-react";
@@ -36,7 +35,6 @@ import { Button } from "../ui/button"; · 26 unmodified lines
3635 import { stackedThreadToast, toastManager } from "../ui/toast";
3736 import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
3837 import { ThemeImportDialog } from "./ThemeImportDialog";
39−import { ThemeSearchDialog } from "./ThemeSearchDialog";
4038 import { useThemeEditorStore } from "./themeEditorStore";
4139 import {
4240 STANDARD_THEME_CARDS,
@@ -252,7 +250,6 @@ export function ThemeLibrary({ · 209 unmodified lines
252250 setThemeHalf: (appearance: ThemeAppearance, themeId: string | null) => boolean;
253251 }) {
254252 const openThemeEditor = useThemeEditorStore((store) => store.openThemeEditor);
255− const [isSearchOpen, setIsSearchOpen] = useState(false);
256253 const [themeToRemove, setThemeToRemove] = useState<ThemeDefinition | null>(null);
257254 // Keep the last removal target so the dialog title stays populated while the
258255 // close animation plays after confirming.
@@ -557,10 +554,6 @@ export function ThemeLibrary({ · 298 unmodified lines
557554 <div className="flex min-h-8 flex-wrap items-center justify-between gap-3 px-3 pt-2 sm:px-4">
558555 <h3 className="text-sm font-medium tracking-[-0.005em] text-foreground">Themes</h3>
559556 <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>
564557 <Button
565558 size="xs"
566559 variant="ghost"
@@ -583,22 +576,6 @@ export function ThemeLibrary({ · 16 unmodified lines
583576 </div>
584577 </div>
585578 {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− />
602579 <ThemeImportDialog
603580 onImportedMany={(importedThemes, { updated }) => {
604581 // An updated theme that is showing (as the base or either half)