1- import { For , onMount , onCleanup , createSignal } from "solid-js" ;
1+ import { For , onMount , onCleanup , createSignal , createResource } from "solid-js" ;
22import { appStore } from "../../stores/app-store" ;
3- import { themes , applyTheme } from "../../themes" ;
4- import { getSetting } from "../../ipc" ;
3+ import { type Theme , getThemes , applyTheme } from "../../themes" ;
4+ import { getSetting , importTheme , deleteCustomTheme , exportTheme } from "../../ipc" ;
55
66export function ThemeSelector ( props ?: { inline ?: boolean } ) {
77 const { setStore } = appStore ;
88 const [ activeId , setActiveId ] = createSignal ( "obsidian-forge" ) ;
9+ const [ themes , { refetch } ] = createResource < Theme [ ] > ( getThemes , { initialValue : [ ] } ) ;
910
1011 onMount ( async ( ) => {
1112 try {
@@ -29,24 +30,95 @@ export function ThemeSelector(props?: { inline?: boolean }) {
2930 onCleanup ( ( ) => window . removeEventListener ( "keydown" , handleKeyDown ) ) ;
3031
3132 function selectTheme ( id : string ) {
32- applyTheme ( id ) ;
33+ applyTheme ( id , themes ( ) ) ;
3334 setActiveId ( id ) ;
3435 }
3536
37+ async function handleImport ( ) {
38+ try {
39+ const { open } = await import ( "@tauri-apps/plugin-dialog" ) ;
40+ const file = await open ( {
41+ multiple : false ,
42+ filters : [ { name : "Theme" , extensions : [ "json" ] } ] ,
43+ } ) ;
44+ if ( ! file ) return ;
45+ const path = typeof file === "string" ? file : file . path ;
46+ // Read file via fetch (Tauri asset protocol) or use fs plugin
47+ // Since we have the path, read it via a small invoke or use the Rust side
48+ // Actually, we pass the path content through the backend — but import_theme expects JSON string.
49+ // Use the web File API via an input element instead for simplicity:
50+ const response = await fetch ( `asset://localhost/${ path } ` ) ;
51+ const content = await response . text ( ) ;
52+ await importTheme ( content ) ;
53+ refetch ( ) ;
54+ } catch ( e ) {
55+ console . error ( "Failed to import theme:" , e ) ;
56+ }
57+ }
58+
59+ async function handleExport ( id : string , name : string ) {
60+ try {
61+ const { save } = await import ( "@tauri-apps/plugin-dialog" ) ;
62+ const path = await save ( {
63+ defaultPath : `${ id } .json` ,
64+ filters : [ { name : "Theme" , extensions : [ "json" ] } ] ,
65+ } ) ;
66+ if ( ! path ) return ;
67+ const content = await exportTheme ( id ) ;
68+ // Write via Tauri fs — but we don't have fs plugin. Use invoke to write or
69+ // use the export content differently. For now, download via blob as fallback.
70+ // Actually, let's write via a simple Rust helper or use the content.
71+ // We can use the backend: the save dialog gives us a path, but we need to write.
72+ // Simplest: use the existing IPC to get content + Blob download.
73+ const blob = new Blob ( [ content ] , { type : "application/json" } ) ;
74+ const url = URL . createObjectURL ( blob ) ;
75+ const a = document . createElement ( "a" ) ;
76+ a . href = url ;
77+ a . download = `${ name . toLowerCase ( ) . replace ( / \s + / g, "-" ) } .json` ;
78+ a . click ( ) ;
79+ URL . revokeObjectURL ( url ) ;
80+ } catch ( e ) {
81+ console . error ( "Failed to export theme:" , e ) ;
82+ }
83+ }
84+
85+ async function handleDelete ( id : string ) {
86+ try {
87+ await deleteCustomTheme ( id ) ;
88+ refetch ( ) ;
89+ // If the deleted theme was active, switch to default
90+ if ( activeId ( ) === id ) {
91+ selectTheme ( "obsidian-forge" ) ;
92+ }
93+ } catch ( e ) {
94+ console . error ( "Failed to delete theme:" , e ) ;
95+ }
96+ }
97+
3698 const content = (
3799 < >
38100 < div class = "theme-selector-header" >
39101 < h2 class = "theme-selector-title" > Themes</ h2 >
40- { ! props ?. inline && (
41- < button class = "theme-close-btn" onClick = { close } title = "Close" >
42- < svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" >
43- < line x1 = "18" y1 = "6" x2 = "6" y2 = "18" /> < line x1 = "6" y1 = "6" x2 = "18" y2 = "18" />
102+ < div class = "theme-header-actions" >
103+ < button class = "theme-import-btn" onClick = { handleImport } title = "Import theme" >
104+ < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" >
105+ < path d = "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
106+ < polyline points = "17 8 12 3 7 8" />
107+ < line x1 = "12" y1 = "3" x2 = "12" y2 = "15" />
44108 </ svg >
109+ < span > Import</ span >
45110 </ button >
46- ) }
111+ { ! props ?. inline && (
112+ < button class = "theme-close-btn" onClick = { close } title = "Close" >
113+ < svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" >
114+ < line x1 = "18" y1 = "6" x2 = "6" y2 = "18" /> < line x1 = "6" y1 = "6" x2 = "18" y2 = "18" />
115+ </ svg >
116+ </ button >
117+ ) }
118+ </ div >
47119 </ div >
48120 < div class = "theme-grid" >
49- < For each = { themes } >
121+ < For each = { themes ( ) } >
50122 { ( theme ) => (
51123 < button
52124 class = "theme-card"
@@ -70,7 +142,35 @@ export function ThemeSelector(props?: { inline?: boolean }) {
70142 < span class = "theme-name" > { theme . name } </ span >
71143 < span class = "theme-desc" > { theme . description } </ span >
72144 </ div >
73- { activeId ( ) === theme . id && < span class = "theme-badge" > Active</ span > }
145+ < div class = "theme-card-badges" >
146+ { activeId ( ) === theme . id && < span class = "theme-badge" > Active</ span > }
147+ { theme . is_custom && < span class = "theme-badge theme-badge-custom" > Custom</ span > }
148+ </ div >
149+ < div class = "theme-card-actions" onClick = { ( e ) => e . stopPropagation ( ) } >
150+ < button
151+ class = "theme-action-btn"
152+ onClick = { ( ) => handleExport ( theme . id , theme . name ) }
153+ title = "Export theme"
154+ >
155+ < svg width = "12" height = "12" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" >
156+ < path d = "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
157+ < polyline points = "7 10 12 15 17 10" />
158+ < line x1 = "12" y1 = "15" x2 = "12" y2 = "3" />
159+ </ svg >
160+ </ button >
161+ { theme . is_custom && (
162+ < button
163+ class = "theme-action-btn theme-action-delete"
164+ onClick = { ( ) => handleDelete ( theme . id ) }
165+ title = "Delete custom theme"
166+ >
167+ < svg width = "12" height = "12" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" >
168+ < polyline points = "3 6 5 6 21 6" />
169+ < path d = "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
170+ </ svg >
171+ </ button >
172+ ) }
173+ </ div >
74174 </ button >
75175 ) }
76176 </ For >
@@ -118,6 +218,30 @@ if (!document.getElementById("theme-selector-styles")) {
118218 color: var(--text);
119219 letter-spacing: -0.3px;
120220 }
221+ .theme-header-actions {
222+ display: flex;
223+ align-items: center;
224+ gap: 8px;
225+ }
226+ .theme-import-btn {
227+ display: flex;
228+ align-items: center;
229+ gap: 5px;
230+ padding: 5px 10px;
231+ border-radius: var(--radius-sm);
232+ font-size: 12px;
233+ font-weight: 500;
234+ color: var(--text-secondary);
235+ background: var(--bg-accent);
236+ border: 1px solid var(--border);
237+ cursor: pointer;
238+ transition: background 0.15s, color 0.15s, border-color 0.15s;
239+ }
240+ .theme-import-btn:hover {
241+ background: var(--bg-muted);
242+ color: var(--text);
243+ border-color: var(--border-strong);
244+ }
121245 .theme-close-btn {
122246 color: var(--text-tertiary);
123247 padding: 5px;
@@ -234,10 +358,14 @@ if (!document.getElementById("theme-selector-styles")) {
234358 -webkit-box-orient: vertical;
235359 overflow: hidden;
236360 }
237- .theme-badge {
361+ .theme-card-badges {
238362 position: absolute;
239363 top: 8px;
240364 right: 8px;
365+ display: flex;
366+ gap: 4px;
367+ }
368+ .theme-badge {
241369 font-size: 10px;
242370 font-weight: 600;
243371 padding: 2px 7px;
@@ -246,6 +374,41 @@ if (!document.getElementById("theme-selector-styles")) {
246374 color: #fff;
247375 letter-spacing: 0.02em;
248376 }
377+ .theme-badge-custom {
378+ background: var(--purple, #b47aff);
379+ }
380+ .theme-card-actions {
381+ position: absolute;
382+ bottom: 8px;
383+ right: 8px;
384+ display: flex;
385+ gap: 4px;
386+ opacity: 0;
387+ transition: opacity 0.15s;
388+ }
389+ .theme-card:hover .theme-card-actions {
390+ opacity: 1;
391+ }
392+ .theme-action-btn {
393+ display: flex;
394+ align-items: center;
395+ justify-content: center;
396+ width: 24px;
397+ height: 24px;
398+ border-radius: var(--radius-sm);
399+ background: var(--bg-surface);
400+ border: 1px solid var(--border);
401+ color: var(--text-secondary);
402+ cursor: pointer;
403+ transition: background 0.15s, color 0.15s;
404+ }
405+ .theme-action-btn:hover {
406+ background: var(--bg-accent);
407+ color: var(--text);
408+ }
409+ .theme-action-delete:hover {
410+ color: var(--red);
411+ }
249412 ` ;
250413 document . head . appendChild ( s ) ;
251414}
0 commit comments