forked from lightningpixel/modly
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathExtensionCard.tsx
More file actions
165 lines (155 loc) · 6.35 KB
/
Copy pathExtensionCard.tsx
File metadata and controls
165 lines (155 loc) · 6.35 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
import type { AnyExtension } from '@shared/types/electron.d'
import type { ExtensionNode } from '@shared/types/electron.d'
export type { AnyExtension as Extension }
export type { ExtensionNode } from '@shared/types/electron.d'
import {
DownloadMap,
ICONS,
IOBadge,
NodeInstallControl,
StatusBadge,
TypePill,
extInstallSummary,
getNodeState,
} from './extensionShared'
interface Props {
ext: AnyExtension
installedIds: string[]
downloading: DownloadMap
loadError?: string
disabled?: boolean
onInstall: (node: ExtensionNode, fullId: string) => void
onInstallAll: (ext: AnyExtension) => void
onPauseDownload: (fullId: string) => void
onCancelDownload: (fullId: string) => void
onOpen: (ext: AnyExtension) => void
}
export function ExtensionCard({
ext, installedIds, downloading, loadError, disabled,
onInstall, onInstallAll, onPauseDownload, onCancelDownload, onOpen,
}: Props): JSX.Element {
const isModel = ext.type === 'model'
const isLocal = typeof ext.source === 'string' && ext.source.startsWith('local://')
const { total, done, installing, hasAvailable } = extInstallSummary(ext, installedIds, downloading)
const handleOpen = (e: React.MouseEvent) => {
if ((e.target as HTMLElement).closest('button')) return
onOpen(ext)
}
const handleKey = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onOpen(ext)
}
}
let status: JSX.Element
if (loadError) {
status = <StatusBadge tone="amber">Load error</StatusBadge>
} else if (installing) {
status = <StatusBadge tone="violet">Installing…</StatusBadge>
} else if (!isModel) {
status = <StatusBadge tone="green">Ready</StatusBadge>
} else if (done === total) {
status = <StatusBadge tone="green">All nodes ready</StatusBadge>
} else {
status = <StatusBadge tone="amber">{done}/{total} nodes installed</StatusBadge>
}
return (
<div
role="button"
tabIndex={0}
onClick={handleOpen}
onKeyDown={handleKey}
aria-label={`${ext.name} — open details`}
className="relative flex flex-col min-h-[218px] p-4 rounded-2xl bg-zinc-900/60 border border-zinc-800 overflow-hidden cursor-pointer transition-all duration-150 hover:bg-zinc-900 hover:border-zinc-700 hover:-translate-y-0.5 hover:shadow-[0_14px_30px_-14px_rgba(0,0,0,0.7)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
{/* Header */}
<div className="flex items-start gap-3">
<div className={`shrink-0 w-10 h-10 p-2.5 rounded-[11px] bg-zinc-800 ring-1 ring-inset ring-zinc-700/50 ${isModel ? 'text-accent-light' : 'text-emerald-400'}`}>
{isModel ? ICONS.spark : ICONS.cube}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-zinc-100 truncate">{ext.name}</span>
<TypePill type={ext.type} />
</div>
<div className="flex items-center gap-1.5 mt-1 text-[11px] text-zinc-600 flex-wrap">
{ext.version && <span className="font-mono text-zinc-500">v{ext.version}</span>}
{ext.version && ext.author && <span className="opacity-50">·</span>}
{ext.author && <span>{ext.author}</span>}
{ext.trusted && (
<>
<span className="opacity-50">·</span>
<span className="inline-flex items-center gap-1 text-zinc-500">
<span className="w-[11px] h-[11px] text-accent-light">{ICONS.shield}</span>
Official
</span>
</>
)}
{isLocal && (
<>
<span className="opacity-50">·</span>
<span className="text-orange-400/80">Local</span>
</>
)}
</div>
</div>
</div>
{/* Description */}
<p className="mt-3 text-xs leading-5 text-zinc-500 line-clamp-2 min-h-[2.5rem]">
{ext.description?.trim() || '—'}
</p>
{/* Load error */}
{loadError && (
<div className="mt-2 px-2.5 py-1.5 rounded-lg bg-red-950/30 border border-red-800/30">
<p className="text-[10px] text-red-400 line-clamp-1 break-all">{loadError}</p>
</div>
)}
{/* Nodes */}
{ext.nodes.length > 0 && (
<div className="mt-3 flex flex-col gap-1.5">
{ext.nodes.map((node) => {
const fullId = `${ext.id}/${node.id}`
const state = getNodeState(ext.id, node, installedIds, downloading)
return (
<div
key={node.id}
className="flex items-center justify-between gap-2.5 px-2.5 py-1.5 rounded-lg bg-white/[0.02] border border-zinc-800"
>
<div className="flex flex-col gap-1 min-w-0 items-start">
<span className="text-xs font-medium text-zinc-200 truncate max-w-full">{node.name}</span>
<IOBadge node={node} />
</div>
{isModel && (
<div className="shrink-0">
<NodeInstallControl
state={state}
disabled={disabled}
onInstall={() => onInstall(node, fullId)}
onPause={() => onPauseDownload(fullId)}
onResume={() => onInstall(node, fullId)}
onCancel={() => onCancelDownload(fullId)}
/>
</div>
)}
</div>
)
})}
</div>
)}
{/* Footer */}
<div className="mt-auto pt-3 flex items-center justify-between gap-2">
{status}
{isModel && hasAvailable && !installing && (
<button
onClick={(e) => { e.stopPropagation(); onInstallAll(ext) }}
disabled={disabled}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold bg-accent/15 text-accent-light ring-1 ring-inset ring-accent/30 hover:bg-accent hover:text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-accent/15 disabled:hover:text-accent-light"
>
<span className="w-3 h-3">{ICONS.download}</span>
Install all
</button>
)}
</div>
</div>
)
}