@skillsgate/tui 0.1.14 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/help-overlay.tsx +1 -0
- package/src/data/api-client.ts +0 -4
- package/src/data/use-skill-actions.ts +2 -14
- package/src/db/local-skills.ts +64 -0
- package/src/db/push.ts +220 -0
- package/src/db/ssh.ts +122 -12
- package/src/store/reducers.ts +0 -12
- package/src/store/types.ts +0 -19
- package/src/utils/colors.ts +2 -0
- package/src/views/push-dialog.tsx +251 -0
- package/src/views/servers.tsx +32 -7
- package/src/data/use-auth.ts +0 -136
- package/src/data/use-favorites.ts +0 -161
- package/src/views/favorites.tsx +0 -19
- package/src/views/login.tsx +0 -202
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// packages/tui/src/views/push-dialog.tsx
|
|
2
|
+
import { useState } from "react"
|
|
3
|
+
import { useKeyboard } from "@opentui/react"
|
|
4
|
+
import { colors } from "../utils/colors.js"
|
|
5
|
+
import type { RemoteServer } from "../db/servers.js"
|
|
6
|
+
import { planPush, applyPush } from "../db/push.js"
|
|
7
|
+
import type { PushPreview, PushResult } from "../db/push.js"
|
|
8
|
+
|
|
9
|
+
interface PushDialogProps {
|
|
10
|
+
server: RemoteServer
|
|
11
|
+
onClose: (result?: PushResult) => void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type Stage = "choose" | "previewing" | "review" | "applying" | "done" | "error"
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* TUI push dialog. State machine: choose -> previewing -> review -> applying -> done | error.
|
|
18
|
+
* Keybindings:
|
|
19
|
+
* m toggle Mirror mode (in choose)
|
|
20
|
+
* Enter advance (choose->preview, review->apply if changes exist)
|
|
21
|
+
* Esc cancel / go back
|
|
22
|
+
*/
|
|
23
|
+
export function PushDialog({ server, onClose }: PushDialogProps) {
|
|
24
|
+
const [mirror, setMirror] = useState(false)
|
|
25
|
+
const [stage, setStage] = useState<Stage>("choose")
|
|
26
|
+
const [preview, setPreview] = useState<PushPreview | null>(null)
|
|
27
|
+
const [result, setResult] = useState<PushResult | null>(null)
|
|
28
|
+
const [error, setError] = useState<string | null>(null)
|
|
29
|
+
|
|
30
|
+
const totalChanges =
|
|
31
|
+
(preview?.toAdd.length ?? 0) +
|
|
32
|
+
(preview?.toUpdate.length ?? 0) +
|
|
33
|
+
(preview?.toDelete.length ?? 0)
|
|
34
|
+
|
|
35
|
+
useKeyboard((key) => {
|
|
36
|
+
if (stage === "previewing" || stage === "applying") return // no input while running
|
|
37
|
+
|
|
38
|
+
if (key.name === "escape") {
|
|
39
|
+
if (stage === "review") {
|
|
40
|
+
// Go back to choose
|
|
41
|
+
setStage("choose")
|
|
42
|
+
setPreview(null)
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
onClose()
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (stage === "choose") {
|
|
50
|
+
if (key.name === "m") {
|
|
51
|
+
setMirror((v) => !v)
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
if (key.name === "return") {
|
|
55
|
+
runPreview()
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (stage === "review") {
|
|
61
|
+
if (key.name === "return" && totalChanges > 0) {
|
|
62
|
+
runApply()
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (stage === "done" || stage === "error") {
|
|
68
|
+
if (key.name === "return" || key.name === "escape") {
|
|
69
|
+
onClose(result ?? undefined)
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
async function runPreview() {
|
|
76
|
+
setStage("previewing")
|
|
77
|
+
setError(null)
|
|
78
|
+
try {
|
|
79
|
+
const p = await planPush(server, { mirror })
|
|
80
|
+
setPreview(p)
|
|
81
|
+
setStage("review")
|
|
82
|
+
} catch (err) {
|
|
83
|
+
setError(err instanceof Error ? err.message : String(err))
|
|
84
|
+
setStage("error")
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function runApply() {
|
|
89
|
+
if (!preview) return
|
|
90
|
+
setStage("applying")
|
|
91
|
+
setError(null)
|
|
92
|
+
try {
|
|
93
|
+
const r = await applyPush(server, preview)
|
|
94
|
+
setResult(r)
|
|
95
|
+
setStage("done")
|
|
96
|
+
} catch (err) {
|
|
97
|
+
setError(err instanceof Error ? err.message : String(err))
|
|
98
|
+
setStage("error")
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<box
|
|
104
|
+
style={{
|
|
105
|
+
width: "100%",
|
|
106
|
+
height: "100%",
|
|
107
|
+
justifyContent: "center",
|
|
108
|
+
alignItems: "center",
|
|
109
|
+
backgroundColor: colors.bg,
|
|
110
|
+
}}
|
|
111
|
+
>
|
|
112
|
+
<box
|
|
113
|
+
style={{
|
|
114
|
+
width: 72,
|
|
115
|
+
border: true,
|
|
116
|
+
borderColor: colors.primary,
|
|
117
|
+
backgroundColor: "#1a1a2e",
|
|
118
|
+
paddingLeft: 2,
|
|
119
|
+
paddingRight: 2,
|
|
120
|
+
paddingTop: 1,
|
|
121
|
+
paddingBottom: 1,
|
|
122
|
+
flexDirection: "column",
|
|
123
|
+
}}
|
|
124
|
+
title={`Push to ${server.label}`}
|
|
125
|
+
>
|
|
126
|
+
{stage === "choose" && (
|
|
127
|
+
<>
|
|
128
|
+
<text fg={colors.primary}>
|
|
129
|
+
<strong>Push to {server.label}</strong>
|
|
130
|
+
</text>
|
|
131
|
+
<text>{" "}</text>
|
|
132
|
+
<text fg={mirror ? colors.textDim : colors.success}>
|
|
133
|
+
{mirror ? " " : "> "}[Push] additive — adds/updates only, never deletes
|
|
134
|
+
</text>
|
|
135
|
+
<text fg={mirror ? colors.warning : colors.textDim}>
|
|
136
|
+
{mirror ? "> " : " "}[Mirror] one-to-one — also deletes remote-only skills
|
|
137
|
+
</text>
|
|
138
|
+
<text>{" "}</text>
|
|
139
|
+
<text fg={colors.textDim}>
|
|
140
|
+
m=toggle mode Enter=preview Esc=cancel
|
|
141
|
+
</text>
|
|
142
|
+
</>
|
|
143
|
+
)}
|
|
144
|
+
|
|
145
|
+
{stage === "previewing" && (
|
|
146
|
+
<>
|
|
147
|
+
<text fg={colors.textDim}>Computing diff...</text>
|
|
148
|
+
</>
|
|
149
|
+
)}
|
|
150
|
+
|
|
151
|
+
{stage === "review" && preview && (
|
|
152
|
+
<>
|
|
153
|
+
<text fg={colors.primary}>
|
|
154
|
+
<strong>Preview</strong>
|
|
155
|
+
{preview.mirror ? (
|
|
156
|
+
<span fg={colors.warning}> (Mirror mode)</span>
|
|
157
|
+
) : null}
|
|
158
|
+
</text>
|
|
159
|
+
<text>{" "}</text>
|
|
160
|
+
<text fg={colors.text}>
|
|
161
|
+
<span fg={colors.success}>{preview.toAdd.length} to add</span>
|
|
162
|
+
{" "}
|
|
163
|
+
<span fg={colors.warning}>{preview.toUpdate.length} to update</span>
|
|
164
|
+
{preview.mirror ? (
|
|
165
|
+
<>
|
|
166
|
+
{" "}
|
|
167
|
+
<span fg={colors.error}>{preview.toDelete.length} to delete</span>
|
|
168
|
+
</>
|
|
169
|
+
) : null}
|
|
170
|
+
{" "}
|
|
171
|
+
<span fg={colors.textDim}>{preview.unchanged.length} unchanged</span>
|
|
172
|
+
</text>
|
|
173
|
+
<text>{" "}</text>
|
|
174
|
+
{totalChanges === 0 ? (
|
|
175
|
+
<text fg={colors.textDim}>Nothing to push — remote already matches local.</text>
|
|
176
|
+
) : (
|
|
177
|
+
<>
|
|
178
|
+
{preview.toAdd.map((e, i) => (
|
|
179
|
+
<text key={`add-${i}`} fg={colors.success}>+ {e.folderName}</text>
|
|
180
|
+
))}
|
|
181
|
+
{preview.toUpdate.map((e, i) => (
|
|
182
|
+
<text key={`upd-${i}`} fg={colors.warning}>~ {e.folderName}</text>
|
|
183
|
+
))}
|
|
184
|
+
{preview.toDelete.map((e, i) => (
|
|
185
|
+
<text key={`del-${i}`} fg={colors.error}>- {e.folderName}</text>
|
|
186
|
+
))}
|
|
187
|
+
</>
|
|
188
|
+
)}
|
|
189
|
+
{preview.mirror && preview.toDelete.length > 0 && (
|
|
190
|
+
<>
|
|
191
|
+
<text>{" "}</text>
|
|
192
|
+
<text fg={colors.error}>
|
|
193
|
+
Mirror will delete {preview.toDelete.length} skill{preview.toDelete.length === 1 ? "" : "s"} from remote.
|
|
194
|
+
</text>
|
|
195
|
+
</>
|
|
196
|
+
)}
|
|
197
|
+
<text>{" "}</text>
|
|
198
|
+
<text fg={colors.textDim}>
|
|
199
|
+
{totalChanges > 0
|
|
200
|
+
? preview.mirror && preview.toDelete.length > 0
|
|
201
|
+
? "Enter=confirm mirror (destructive) Esc=back"
|
|
202
|
+
: "Enter=apply Esc=back"
|
|
203
|
+
: "Esc=close"}
|
|
204
|
+
</text>
|
|
205
|
+
</>
|
|
206
|
+
)}
|
|
207
|
+
|
|
208
|
+
{stage === "applying" && (
|
|
209
|
+
<text fg={colors.textDim}>Pushing to remote...</text>
|
|
210
|
+
)}
|
|
211
|
+
|
|
212
|
+
{stage === "done" && result && (
|
|
213
|
+
<>
|
|
214
|
+
<text fg={colors.success}><strong>Push complete</strong></text>
|
|
215
|
+
<text>{" "}</text>
|
|
216
|
+
<text fg={colors.text}>
|
|
217
|
+
<span fg={colors.success}>{result.added} added</span>
|
|
218
|
+
{" "}
|
|
219
|
+
<span fg={colors.warning}>{result.updated} updated</span>
|
|
220
|
+
{" "}
|
|
221
|
+
<span fg={result.deleted > 0 ? colors.error : colors.textDim}>{result.deleted} deleted</span>
|
|
222
|
+
{" "}
|
|
223
|
+
<span fg={colors.textDim}>{result.unchanged} unchanged</span>
|
|
224
|
+
</text>
|
|
225
|
+
{result.errors.length > 0 && (
|
|
226
|
+
<>
|
|
227
|
+
<text>{" "}</text>
|
|
228
|
+
<text fg={colors.error}>Errors ({result.errors.length}):</text>
|
|
229
|
+
{result.errors.map((e, i) => (
|
|
230
|
+
<text key={i} fg={colors.error}> {e.folderName}: {e.message}</text>
|
|
231
|
+
))}
|
|
232
|
+
</>
|
|
233
|
+
)}
|
|
234
|
+
<text>{" "}</text>
|
|
235
|
+
<text fg={colors.textDim}>Enter or Esc to close</text>
|
|
236
|
+
</>
|
|
237
|
+
)}
|
|
238
|
+
|
|
239
|
+
{stage === "error" && (
|
|
240
|
+
<>
|
|
241
|
+
<text fg={colors.error}><strong>Push failed</strong></text>
|
|
242
|
+
<text>{" "}</text>
|
|
243
|
+
<text fg={colors.error}>{error || "Unknown error"}</text>
|
|
244
|
+
<text>{" "}</text>
|
|
245
|
+
<text fg={colors.textDim}>Enter or Esc to close</text>
|
|
246
|
+
</>
|
|
247
|
+
)}
|
|
248
|
+
</box>
|
|
249
|
+
</box>
|
|
250
|
+
)
|
|
251
|
+
}
|
package/src/views/servers.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import { ConfirmDialog } from "../components/confirm-dialog.js"
|
|
|
6
6
|
import { testConnection, syncRemoteServer } from "../db/ssh.js"
|
|
7
7
|
import { colors } from "../utils/colors.js"
|
|
8
8
|
import type { RemoteServer } from "../db/servers.js"
|
|
9
|
+
import { PushDialog } from "./push-dialog.js"
|
|
9
10
|
|
|
10
11
|
interface ServersViewProps {
|
|
11
12
|
onServerCountChange: (count: number) => void
|
|
@@ -40,6 +41,7 @@ export function ServersView({ onServerCountChange }: ServersViewProps) {
|
|
|
40
41
|
const [serverList, setServerList] = useState<RemoteServer[]>(() => servers.list())
|
|
41
42
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
42
43
|
const [pendingAction, setPendingAction] = useState<PendingAction>(null)
|
|
44
|
+
const [pushTarget, setPushTarget] = useState<RemoteServer | null>(null)
|
|
43
45
|
const [syncing, setSyncing] = useState(false)
|
|
44
46
|
const [syncLog, setSyncLog] = useState<string[] | null>(null)
|
|
45
47
|
const [testing, setTesting] = useState(false)
|
|
@@ -67,6 +69,7 @@ export function ServersView({ onServerCountChange }: ServersViewProps) {
|
|
|
67
69
|
if (state.activeView !== "servers") return
|
|
68
70
|
if (state.showHelp) return
|
|
69
71
|
if (pendingAction) return
|
|
72
|
+
if (pushTarget) return
|
|
70
73
|
if (syncing || testing) return
|
|
71
74
|
|
|
72
75
|
// j/k or arrow keys
|
|
@@ -116,6 +119,12 @@ export function ServersView({ onServerCountChange }: ServersViewProps) {
|
|
|
116
119
|
return
|
|
117
120
|
}
|
|
118
121
|
|
|
122
|
+
// P = push to selected server
|
|
123
|
+
if (key.name === "P" && selectedServer) {
|
|
124
|
+
setPushTarget(selectedServer)
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
|
|
119
128
|
// Enter = browse server's skills
|
|
120
129
|
if (key.name === "return" && selectedServer) {
|
|
121
130
|
const count = skillCounts.get(selectedServer.id) ?? 0
|
|
@@ -169,18 +178,21 @@ export function ServersView({ onServerCountChange }: ServersViewProps) {
|
|
|
169
178
|
setSyncing(false)
|
|
170
179
|
refreshList()
|
|
171
180
|
|
|
172
|
-
if (result.
|
|
181
|
+
if (result.error) {
|
|
173
182
|
dispatch({
|
|
174
183
|
type: "SHOW_NOTIFICATION",
|
|
175
184
|
notification: {
|
|
176
|
-
type: "
|
|
177
|
-
message: `
|
|
185
|
+
type: "error",
|
|
186
|
+
message: `Sync failed for ${server.label}: ${result.error}`,
|
|
178
187
|
},
|
|
179
188
|
})
|
|
180
|
-
} else if (result.
|
|
189
|
+
} else if (result.total > 0 || result.removed > 0) {
|
|
181
190
|
dispatch({
|
|
182
191
|
type: "SHOW_NOTIFICATION",
|
|
183
|
-
notification: {
|
|
192
|
+
notification: {
|
|
193
|
+
type: "success",
|
|
194
|
+
message: `Synced ${server.label}: ${result.added} new, ${result.updated} updated, ${result.removed} removed`,
|
|
195
|
+
},
|
|
184
196
|
})
|
|
185
197
|
} else {
|
|
186
198
|
dispatch({
|
|
@@ -190,6 +202,19 @@ export function ServersView({ onServerCountChange }: ServersViewProps) {
|
|
|
190
202
|
}
|
|
191
203
|
}, [servers, skills, dispatch, refreshList])
|
|
192
204
|
|
|
205
|
+
// Push dialog
|
|
206
|
+
if (pushTarget) {
|
|
207
|
+
return (
|
|
208
|
+
<PushDialog
|
|
209
|
+
server={pushTarget}
|
|
210
|
+
onClose={() => {
|
|
211
|
+
setPushTarget(null)
|
|
212
|
+
refreshList()
|
|
213
|
+
}}
|
|
214
|
+
/>
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
|
|
193
218
|
// Confirm dialog for delete
|
|
194
219
|
if (pendingAction?.type === "delete") {
|
|
195
220
|
return (
|
|
@@ -324,7 +349,7 @@ export function ServersView({ onServerCountChange }: ServersViewProps) {
|
|
|
324
349
|
{/* Bottom shortcut hints */}
|
|
325
350
|
<box style={{ height: 1, paddingLeft: 1, backgroundColor: colors.bgAlt }}>
|
|
326
351
|
<text fg={colors.textDim}>
|
|
327
|
-
S=sync Enter=browse a=add e=edit d=delete t=test
|
|
352
|
+
S=sync P=push Enter=browse a=add e=edit d=delete t=test
|
|
328
353
|
</text>
|
|
329
354
|
</box>
|
|
330
355
|
</box>
|
|
@@ -397,7 +422,7 @@ function ServerDetailPanel({ server, skillCount }: ServerDetailPanelProps) {
|
|
|
397
422
|
) : null}
|
|
398
423
|
|
|
399
424
|
<text fg={colors.border}>---</text>
|
|
400
|
-
<text fg={colors.textDim}>S=sync t=test e=edit d=delete Enter=browse skills</text>
|
|
425
|
+
<text fg={colors.textDim}>S=sync P=push t=test e=edit d=delete Enter=browse skills</text>
|
|
401
426
|
</box>
|
|
402
427
|
)
|
|
403
428
|
}
|
package/src/data/use-auth.ts
DELETED
|
@@ -1,136 +0,0 @@
|
|
|
1
|
-
import { useEffect, useCallback } from "react"
|
|
2
|
-
import { useStore, useDispatch } from "../store/context.js"
|
|
3
|
-
import { useDb } from "../db/context.js"
|
|
4
|
-
|
|
5
|
-
const API_BASE_URL = process.env.SKILLSGATE_API_URL ?? "https://skillsgate.ai"
|
|
6
|
-
|
|
7
|
-
// SQLite settings keys for auth
|
|
8
|
-
const AUTH_TOKEN_KEY = "auth.token"
|
|
9
|
-
const AUTH_USER_KEY = "auth.user"
|
|
10
|
-
|
|
11
|
-
interface AuthUser {
|
|
12
|
-
id: string
|
|
13
|
-
name: string
|
|
14
|
-
email: string
|
|
15
|
-
image?: string
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
interface ExchangeResponse {
|
|
19
|
-
access_token: string
|
|
20
|
-
user: AuthUser
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Auth hook using the shared SQLite database.
|
|
25
|
-
* Both TUI and Electron read/write auth to the same DB at ~/.skillsgate/skillsgate.db
|
|
26
|
-
* No keyring dependency -- works in both Bun and Node.
|
|
27
|
-
*/
|
|
28
|
-
export function useAuth() {
|
|
29
|
-
const state = useStore()
|
|
30
|
-
const dispatch = useDispatch()
|
|
31
|
-
const { settings } = useDb()
|
|
32
|
-
|
|
33
|
-
// On mount, load auth from SQLite
|
|
34
|
-
useEffect(() => {
|
|
35
|
-
let cancelled = false
|
|
36
|
-
|
|
37
|
-
try {
|
|
38
|
-
const token = settings.get<string | null>(AUTH_TOKEN_KEY, null)
|
|
39
|
-
const user = settings.get<AuthUser | null>(AUTH_USER_KEY, null)
|
|
40
|
-
|
|
41
|
-
if (token && user) {
|
|
42
|
-
dispatch({
|
|
43
|
-
type: "SET_AUTH",
|
|
44
|
-
auth: { token, user },
|
|
45
|
-
})
|
|
46
|
-
} else {
|
|
47
|
-
// Try legacy file-based auth as fallback
|
|
48
|
-
loadLegacyAuth().then((legacy) => {
|
|
49
|
-
if (cancelled) return
|
|
50
|
-
if (legacy) {
|
|
51
|
-
settings.set(AUTH_TOKEN_KEY, legacy.token)
|
|
52
|
-
settings.set(AUTH_USER_KEY, legacy.user)
|
|
53
|
-
dispatch({
|
|
54
|
-
type: "SET_AUTH",
|
|
55
|
-
auth: { token: legacy.token, user: legacy.user },
|
|
56
|
-
})
|
|
57
|
-
} else {
|
|
58
|
-
dispatch({ type: "SET_AUTH", auth: null })
|
|
59
|
-
}
|
|
60
|
-
}).catch(() => {
|
|
61
|
-
if (!cancelled) dispatch({ type: "SET_AUTH", auth: null })
|
|
62
|
-
})
|
|
63
|
-
}
|
|
64
|
-
} catch {
|
|
65
|
-
dispatch({ type: "SET_AUTH", auth: null })
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
return () => { cancelled = true }
|
|
69
|
-
}, [])
|
|
70
|
-
|
|
71
|
-
const login = useCallback(async (code: string): Promise<string | null> => {
|
|
72
|
-
try {
|
|
73
|
-
const res = await fetch(`${API_BASE_URL}/api/auth/device/exchange`, {
|
|
74
|
-
method: "POST",
|
|
75
|
-
headers: { "Content-Type": "application/json" },
|
|
76
|
-
body: JSON.stringify({ code }),
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
if (res.ok) {
|
|
80
|
-
const result = (await res.json()) as ExchangeResponse
|
|
81
|
-
|
|
82
|
-
// Save to SQLite (shared with Electron)
|
|
83
|
-
settings.set(AUTH_TOKEN_KEY, result.access_token)
|
|
84
|
-
settings.set(AUTH_USER_KEY, result.user)
|
|
85
|
-
|
|
86
|
-
dispatch({
|
|
87
|
-
type: "SET_AUTH",
|
|
88
|
-
auth: { token: result.access_token, user: result.user },
|
|
89
|
-
})
|
|
90
|
-
return null
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const data = (await res.json().catch(() => ({}))) as { error?: string }
|
|
94
|
-
|
|
95
|
-
if (data?.error === "rate_limited") {
|
|
96
|
-
return "Too many attempts. Please wait a minute and try again."
|
|
97
|
-
} else if (data?.error === "invalid_code") {
|
|
98
|
-
return "Invalid code. Please check and try again."
|
|
99
|
-
} else if (data?.error === "expired") {
|
|
100
|
-
return "Code has expired. Get a new one from the browser."
|
|
101
|
-
}
|
|
102
|
-
return "Something went wrong. Please try again."
|
|
103
|
-
} catch {
|
|
104
|
-
return "Network error. Please check your connection and try again."
|
|
105
|
-
}
|
|
106
|
-
}, [dispatch, settings])
|
|
107
|
-
|
|
108
|
-
const logout = useCallback(async () => {
|
|
109
|
-
settings.set(AUTH_TOKEN_KEY, null)
|
|
110
|
-
settings.set(AUTH_USER_KEY, null)
|
|
111
|
-
dispatch({ type: "SET_AUTH", auth: null })
|
|
112
|
-
}, [dispatch, settings])
|
|
113
|
-
|
|
114
|
-
return {
|
|
115
|
-
auth: state.auth,
|
|
116
|
-
login,
|
|
117
|
-
logout,
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Try to load auth from the legacy CLI file (~/.skillsgate/auth.json + keyring).
|
|
123
|
-
* Used as a one-time migration to SQLite.
|
|
124
|
-
*/
|
|
125
|
-
async function loadLegacyAuth(): Promise<{ token: string; user: AuthUser } | null> {
|
|
126
|
-
try {
|
|
127
|
-
const { loadAuth } = await import("../../../cli/src/utils/auth-store.js")
|
|
128
|
-
const stored = await loadAuth()
|
|
129
|
-
if (stored?.token && stored?.user) {
|
|
130
|
-
return { token: stored.token, user: stored.user }
|
|
131
|
-
}
|
|
132
|
-
} catch {
|
|
133
|
-
// CLI auth-store not available or keyring failed
|
|
134
|
-
}
|
|
135
|
-
return null
|
|
136
|
-
}
|
|
@@ -1,161 +0,0 @@
|
|
|
1
|
-
import { useState, useEffect, useCallback } from "react"
|
|
2
|
-
import { useStore, useDispatch } from "../store/context.js"
|
|
3
|
-
import { SKILLSGATE_API_BASE } from "./api-client.js"
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Shape returned by the favorites API endpoint.
|
|
7
|
-
*/
|
|
8
|
-
export interface FavoriteSkill {
|
|
9
|
-
id: string
|
|
10
|
-
name: string
|
|
11
|
-
description: string
|
|
12
|
-
summary?: string
|
|
13
|
-
categories: string[]
|
|
14
|
-
capabilities?: string[]
|
|
15
|
-
keywords?: string[]
|
|
16
|
-
githubUrl?: string
|
|
17
|
-
githubStars?: number | null
|
|
18
|
-
installCommand?: string | null
|
|
19
|
-
slug?: string
|
|
20
|
-
favoriteId?: string
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const API_BASE = SKILLSGATE_API_BASE
|
|
24
|
-
|
|
25
|
-
interface UseFavoritesResult {
|
|
26
|
-
favorites: FavoriteSkill[]
|
|
27
|
-
loading: boolean
|
|
28
|
-
error: string | null
|
|
29
|
-
toggle: (skillId: string) => Promise<void>
|
|
30
|
-
refresh: () => void
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Hook that manages the user's favorited skills.
|
|
35
|
-
* Requires authentication -- returns empty list if not logged in.
|
|
36
|
-
*/
|
|
37
|
-
export function useFavorites(): UseFavoritesResult {
|
|
38
|
-
const state = useStore()
|
|
39
|
-
const dispatch = useDispatch()
|
|
40
|
-
const [favorites, setFavorites] = useState<FavoriteSkill[]>([])
|
|
41
|
-
const [loading, setLoading] = useState(false)
|
|
42
|
-
const [error, setError] = useState<string | null>(null)
|
|
43
|
-
const [refreshToken, setRefreshToken] = useState(0)
|
|
44
|
-
|
|
45
|
-
const token = state.auth?.token
|
|
46
|
-
|
|
47
|
-
// Fetch favorites when authenticated
|
|
48
|
-
useEffect(() => {
|
|
49
|
-
if (!token) {
|
|
50
|
-
setFavorites([])
|
|
51
|
-
setLoading(false)
|
|
52
|
-
return
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
let cancelled = false
|
|
56
|
-
setLoading(true)
|
|
57
|
-
setError(null)
|
|
58
|
-
|
|
59
|
-
async function fetchFavorites() {
|
|
60
|
-
try {
|
|
61
|
-
const res = await fetch(`${API_BASE}/api/favorites`, {
|
|
62
|
-
headers: {
|
|
63
|
-
"Content-Type": "application/json",
|
|
64
|
-
Authorization: `Bearer ${token}`,
|
|
65
|
-
},
|
|
66
|
-
})
|
|
67
|
-
|
|
68
|
-
if (!res.ok) {
|
|
69
|
-
throw new Error(`Failed to fetch favorites (HTTP ${res.status})`)
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const data = (await res.json()) as { favorites: FavoriteSkill[] }
|
|
73
|
-
if (!cancelled) {
|
|
74
|
-
const items = data.favorites ?? []
|
|
75
|
-
setFavorites(items)
|
|
76
|
-
dispatch({ type: "SET_FAVORITES", favorites: items })
|
|
77
|
-
}
|
|
78
|
-
} catch (err) {
|
|
79
|
-
if (!cancelled) {
|
|
80
|
-
const msg = err instanceof Error ? err.message : String(err)
|
|
81
|
-
setError(msg)
|
|
82
|
-
setFavorites([])
|
|
83
|
-
dispatch({ type: "SET_FAVORITES", favorites: [] })
|
|
84
|
-
}
|
|
85
|
-
} finally {
|
|
86
|
-
if (!cancelled) {
|
|
87
|
-
setLoading(false)
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
fetchFavorites()
|
|
93
|
-
return () => { cancelled = true }
|
|
94
|
-
}, [token, refreshToken])
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Toggle a skill's favorite status.
|
|
98
|
-
* If already favorited, removes it. Otherwise, adds it.
|
|
99
|
-
*/
|
|
100
|
-
const toggle = useCallback(async (skillId: string) => {
|
|
101
|
-
if (!token) return
|
|
102
|
-
|
|
103
|
-
const isFavorited = favorites.some((f) => f.id === skillId)
|
|
104
|
-
|
|
105
|
-
try {
|
|
106
|
-
if (isFavorited) {
|
|
107
|
-
// Remove favorite
|
|
108
|
-
const res = await fetch(`${API_BASE}/api/favorites/${skillId}`, {
|
|
109
|
-
method: "DELETE",
|
|
110
|
-
headers: {
|
|
111
|
-
Authorization: `Bearer ${token}`,
|
|
112
|
-
},
|
|
113
|
-
})
|
|
114
|
-
if (!res.ok) {
|
|
115
|
-
throw new Error(`Failed to remove favorite (HTTP ${res.status})`)
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// Optimistic update: remove from local list
|
|
119
|
-
setFavorites((prev) => {
|
|
120
|
-
const updated = prev.filter((f) => f.id !== skillId)
|
|
121
|
-
dispatch({ type: "SET_FAVORITES", favorites: updated })
|
|
122
|
-
return updated
|
|
123
|
-
})
|
|
124
|
-
} else {
|
|
125
|
-
// Add favorite
|
|
126
|
-
const res = await fetch(`${API_BASE}/api/favorites`, {
|
|
127
|
-
method: "POST",
|
|
128
|
-
headers: {
|
|
129
|
-
"Content-Type": "application/json",
|
|
130
|
-
Authorization: `Bearer ${token}`,
|
|
131
|
-
},
|
|
132
|
-
body: JSON.stringify({ skillId }),
|
|
133
|
-
})
|
|
134
|
-
if (!res.ok) {
|
|
135
|
-
throw new Error(`Failed to add favorite (HTTP ${res.status})`)
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Refresh the full list to get the complete skill data
|
|
139
|
-
setRefreshToken((t) => t + 1)
|
|
140
|
-
}
|
|
141
|
-
} catch (err) {
|
|
142
|
-
const msg = err instanceof Error ? err.message : String(err)
|
|
143
|
-
dispatch({
|
|
144
|
-
type: "SHOW_NOTIFICATION",
|
|
145
|
-
notification: { type: "error", message: msg },
|
|
146
|
-
})
|
|
147
|
-
}
|
|
148
|
-
}, [token, favorites, dispatch])
|
|
149
|
-
|
|
150
|
-
const refresh = useCallback(() => {
|
|
151
|
-
setRefreshToken((t) => t + 1)
|
|
152
|
-
}, [])
|
|
153
|
-
|
|
154
|
-
return {
|
|
155
|
-
favorites,
|
|
156
|
-
loading,
|
|
157
|
-
error,
|
|
158
|
-
toggle,
|
|
159
|
-
refresh,
|
|
160
|
-
}
|
|
161
|
-
}
|
package/src/views/favorites.tsx
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import { colors } from "../utils/colors.js"
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Favorites view: Coming soon placeholder.
|
|
5
|
-
* Favorites require authentication which is not yet available in the public TUI.
|
|
6
|
-
*/
|
|
7
|
-
export function FavoritesView() {
|
|
8
|
-
return (
|
|
9
|
-
<box style={{ flexDirection: "column", padding: 2 }}>
|
|
10
|
-
<text fg={colors.primary}>
|
|
11
|
-
<strong>Favorites</strong>
|
|
12
|
-
</text>
|
|
13
|
-
<text>{" "}</text>
|
|
14
|
-
<text fg={colors.text}>
|
|
15
|
-
Coming soon. Favorites will be available once accounts are launched.
|
|
16
|
-
</text>
|
|
17
|
-
</box>
|
|
18
|
-
)
|
|
19
|
-
}
|