aidevops 3.29.20 → 3.29.22
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/README.md +1 -1
- package/VERSION +1 -1
- package/aidevops.sh +1 -1
- package/package.json +1 -1
- package/packages/gui-web/src/AppActionTerminal.tsx +81 -0
- package/packages/gui-web/src/InventoryEditorSurfaces.tsx +99 -0
- package/packages/gui-web/src/InventorySurfaces.tsx +27 -498
- package/packages/gui-web/src/ManagedAppPanel.tsx +141 -0
- package/packages/gui-web/src/RecommendedAppsSurface.tsx +223 -0
- package/setup.sh +1 -1
package/README.md
CHANGED
|
@@ -58,7 +58,7 @@ The result: an AI operations platform that manages projects across every busines
|
|
|
58
58
|
[](https://github.com/marcusquinn)
|
|
59
59
|
|
|
60
60
|
<!-- Release & Version Info -->
|
|
61
|
-
[](https://github.com/marcusquinn/aidevops/releases)
|
|
62
62
|
[](https://www.npmjs.com/package/aidevops)
|
|
63
63
|
[](https://github.com/marcusquinn/homebrew-tap)
|
|
64
64
|
[](https://github.com/marcusquinn/aidevops)
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.29.
|
|
1
|
+
3.29.22
|
package/aidevops.sh
CHANGED
package/package.json
CHANGED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { GuiAppActionJobSummary } from "@aidevops/gui-shared";
|
|
2
|
+
import { type ReactElement, type ReactNode, useEffect, useRef } from "react";
|
|
3
|
+
import { FiX } from "react-icons/fi";
|
|
4
|
+
|
|
5
|
+
const ANSI_PATTERN = /\x1b\[([0-9;]*)m/g;
|
|
6
|
+
|
|
7
|
+
export function AppActionTerminal({ job, onDismiss }: { job: GuiAppActionJobSummary; onDismiss: (jobId: string) => void }): ReactElement {
|
|
8
|
+
const outputRef = useRef<HTMLPreElement | null>(null);
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
if (outputRef.current !== null) {
|
|
12
|
+
outputRef.current.scrollTop = outputRef.current.scrollHeight;
|
|
13
|
+
}
|
|
14
|
+
}, [job.output.length]);
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<section className="app-terminal" id={`app-job-${job.id}`} aria-label="App action terminal output">
|
|
18
|
+
<header><strong>{job.app_id} {job.action}</strong><span>{terminalStatusLabel(job)}</span><button aria-label={`Dismiss ${job.app_id} ${job.action} terminal output`} className="terminal-close-button" onClick={() => onDismiss(job.id)} title="Dismiss terminal output" type="button"><FiX aria-hidden="true" /></button></header>
|
|
19
|
+
<pre ref={outputRef}>{renderTerminalOutput(job.output)}</pre>
|
|
20
|
+
</section>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function terminalStatusLabel(job: GuiAppActionJobSummary): string {
|
|
25
|
+
if (job.status === "running") {
|
|
26
|
+
return "running";
|
|
27
|
+
}
|
|
28
|
+
if (job.exit_code === null) {
|
|
29
|
+
return job.status;
|
|
30
|
+
}
|
|
31
|
+
return `${job.status} · exit ${job.exit_code}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function renderTerminalOutput(lines: string[]): ReactNode[] {
|
|
35
|
+
return lines.flatMap((line, index) => [
|
|
36
|
+
...renderAnsiLine(line, `line-${index}`),
|
|
37
|
+
index === lines.length - 1 ? null : "\n",
|
|
38
|
+
]).filter((node): node is ReactNode => node !== null);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function renderAnsiLine(line: string, keyPrefix: string): ReactNode[] {
|
|
42
|
+
const nodes: ReactNode[] = [];
|
|
43
|
+
ANSI_PATTERN.lastIndex = 0;
|
|
44
|
+
let activeClass = "";
|
|
45
|
+
let lastIndex = 0;
|
|
46
|
+
let match = ANSI_PATTERN.exec(line);
|
|
47
|
+
|
|
48
|
+
while (match !== null) {
|
|
49
|
+
if (match.index > lastIndex) {
|
|
50
|
+
nodes.push(terminalSpan(line.slice(lastIndex, match.index), activeClass, `${keyPrefix}-${nodes.length}`));
|
|
51
|
+
}
|
|
52
|
+
activeClass = ansiClassForCodes(match[1]);
|
|
53
|
+
lastIndex = ANSI_PATTERN.lastIndex;
|
|
54
|
+
match = ANSI_PATTERN.exec(line);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (lastIndex < line.length) {
|
|
58
|
+
nodes.push(terminalSpan(line.slice(lastIndex), activeClass, `${keyPrefix}-${nodes.length}`));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (nodes.length === 0) {
|
|
62
|
+
nodes.push(terminalSpan(line, activeClass, `${keyPrefix}-0`));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return nodes;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function terminalSpan(textValue: string, className: string, key: string): ReactNode {
|
|
69
|
+
const promptClass = textValue.startsWith("$ ") ? "ansi-prompt" : "";
|
|
70
|
+
const combinedClass = [className, promptClass].filter(Boolean).join(" ");
|
|
71
|
+
return combinedClass.length > 0 ? <span className={combinedClass} key={key}>{textValue}</span> : textValue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function ansiClassForCodes(rawCodes: string): string {
|
|
75
|
+
const codes = rawCodes.length === 0 ? [0] : rawCodes.split(";").map((code) => Number.parseInt(code, 10));
|
|
76
|
+
const colorCode = [...codes].reverse().find((code) => (code >= 30 && code <= 37) || (code >= 90 && code <= 97));
|
|
77
|
+
if (colorCode === undefined) {
|
|
78
|
+
return "";
|
|
79
|
+
}
|
|
80
|
+
return `ansi-fg-${colorCode}`;
|
|
81
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { type ReactElement, useState } from "react";
|
|
2
|
+
import type { InventoryColumn } from "./app-model";
|
|
3
|
+
import { installationRows, text } from "./app-model";
|
|
4
|
+
|
|
5
|
+
interface DraftInventoryRow {
|
|
6
|
+
id: string;
|
|
7
|
+
values: Record<string, string>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
let draftRowCounter = 0;
|
|
11
|
+
|
|
12
|
+
export function InstallationSurface(): ReactElement {
|
|
13
|
+
return (
|
|
14
|
+
<section className="panel" aria-label={text.installation}>
|
|
15
|
+
<div className="section-heading">
|
|
16
|
+
<p className="eyebrow">{text.setup}</p>
|
|
17
|
+
<h2>{text.installation}</h2>
|
|
18
|
+
<p>{text.installationIntro}</p>
|
|
19
|
+
</div>
|
|
20
|
+
<div className="installation-list">
|
|
21
|
+
{installationRows.map((row) => (
|
|
22
|
+
<article className="install-row" key={row.name}>
|
|
23
|
+
<div><strong>{row.name}</strong><small>{row.scope}</small></div>
|
|
24
|
+
<TogglePill checked={row.install} label={text.install} />
|
|
25
|
+
<TogglePill checked={row.update} label={text.update} />
|
|
26
|
+
</article>
|
|
27
|
+
))}
|
|
28
|
+
</div>
|
|
29
|
+
<p className="empty-state">{text.plannedNotice}</p>
|
|
30
|
+
</section>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function EditableInventorySurface({ columns, initialRows, intro, title }: {
|
|
35
|
+
columns: InventoryColumn[];
|
|
36
|
+
initialRows: Record<string, string>[];
|
|
37
|
+
intro: string;
|
|
38
|
+
title: string;
|
|
39
|
+
}): ReactElement {
|
|
40
|
+
const [draftRows, setDraftRows] = useState(() => initialRows.map((row) => createDraftRow(row)));
|
|
41
|
+
|
|
42
|
+
function updateDraftRow(rowId: string, key: string, value: string): void {
|
|
43
|
+
setDraftRows((currentRows) => currentRows.map((row) => row.id === rowId ? { ...row, values: { ...row.values, [key]: value } } : row));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function addDraftRow(): void {
|
|
47
|
+
setDraftRows((currentRows) => [...currentRows, createDraftRow(emptyRow(columns))]);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<section className="panel" aria-label={title}>
|
|
52
|
+
<div className="section-heading split-heading">
|
|
53
|
+
<div>
|
|
54
|
+
<p className="eyebrow">{text.infrastructure}</p>
|
|
55
|
+
<h2>{title}</h2>
|
|
56
|
+
<p>{intro}</p>
|
|
57
|
+
</div>
|
|
58
|
+
<button className="secondary-action" onClick={addDraftRow} type="button">{text.addRow}</button>
|
|
59
|
+
</div>
|
|
60
|
+
<p className="notice compact-notice">{text.draftOnly}</p>
|
|
61
|
+
<div className="editable-table">
|
|
62
|
+
<div className="editable-row header-row">
|
|
63
|
+
{columns.map((column) => <span key={column.key}>{column.label}</span>)}
|
|
64
|
+
</div>
|
|
65
|
+
{draftRows.map((row) => (
|
|
66
|
+
<div className="editable-row" key={row.id}>
|
|
67
|
+
{columns.map((column) => (
|
|
68
|
+
<input
|
|
69
|
+
aria-label={`${title} ${column.label}`}
|
|
70
|
+
key={column.key}
|
|
71
|
+
onChange={(event) => updateDraftRow(row.id, column.key, event.currentTarget.value)}
|
|
72
|
+
placeholder={column.label}
|
|
73
|
+
value={row.values[column.key] ?? ""}
|
|
74
|
+
/>
|
|
75
|
+
))}
|
|
76
|
+
</div>
|
|
77
|
+
))}
|
|
78
|
+
</div>
|
|
79
|
+
</section>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function TogglePill({ checked, label }: { checked: boolean; label: string }): ReactElement {
|
|
84
|
+
return <span className={checked ? "toggle-pill checked" : "toggle-pill"}>{label}</span>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function createDraftRow(values: Record<string, string>): DraftInventoryRow {
|
|
88
|
+
draftRowCounter += 1;
|
|
89
|
+
return { id: `draft-row-${draftRowCounter}`, values };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function emptyRow(columns: InventoryColumn[]): Record<string, string> {
|
|
93
|
+
const row: Record<string, string> = {};
|
|
94
|
+
for (const column of columns) {
|
|
95
|
+
row[column.key] = "";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return row;
|
|
99
|
+
}
|
|
@@ -1,19 +1,11 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import { type CSSProperties, type Dispatch, type FocusEvent as ReactFocusEvent, type
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { IoLogoAndroid } from "react-icons/io";
|
|
7
|
-
import { SiIos } from "react-icons/si";
|
|
8
|
-
import type { InventoryColumn } from "./app-model";
|
|
9
|
-
import { installationRows, text } from "./app-model";
|
|
10
|
-
|
|
11
|
-
interface DraftInventoryRow {
|
|
12
|
-
id: string;
|
|
13
|
-
values: Record<string, string>;
|
|
14
|
-
}
|
|
1
|
+
import type { GuiAppActionJobSummary, GuiManagedAppSummary, GuiResponseEnvelope, GuiStatusData } from "@aidevops/gui-shared";
|
|
2
|
+
import { type CSSProperties, type Dispatch, type FocusEvent as ReactFocusEvent, type PointerEvent as ReactPointerEvent, type ReactElement, type SetStateAction, useEffect, useState } from "react";
|
|
3
|
+
import { ManagedAppPanel } from "./ManagedAppPanel";
|
|
4
|
+
import { text } from "./app-model";
|
|
5
|
+
import { RecommendedAppsSurface, nextRecommendedFilterValue, recommendedAppMatchesFilters, recommendedApps, type RecommendedOsId, type RecommendedPlatformFilterId } from "./RecommendedAppsSurface";
|
|
15
6
|
|
|
16
|
-
|
|
7
|
+
export { EditableInventorySurface, InstallationSurface } from "./InventoryEditorSurfaces";
|
|
8
|
+
export { nextRecommendedFilterValue } from "./RecommendedAppsSurface";
|
|
17
9
|
|
|
18
10
|
export function AppsSurface({ status }: { status: GuiStatusData }): ReactElement {
|
|
19
11
|
const [appCollection, setAppCollection] = useState<AppCollectionId>("aidevops");
|
|
@@ -110,187 +102,6 @@ export function AppsSurface({ status }: { status: GuiStatusData }): ReactElement
|
|
|
110
102
|
);
|
|
111
103
|
}
|
|
112
104
|
|
|
113
|
-
function ManagedAppPanel({ app, dismissedJobIds, expanded, job, onDismissJob, onJob, onPolicyToggle, onToggle, policyJobs }: { app: GuiManagedAppSummary; dismissedJobIds: Set<string>; expanded: boolean; job: GuiAppActionJobSummary | null; onDismissJob: (jobId: string) => void; onJob: (job: GuiAppActionJobSummary) => void; onPolicyToggle: (policy: ManagedPolicyId, value: boolean) => void; onToggle: () => void; policyJobs: GuiAppActionJobSummary[] }): ReactElement {
|
|
114
|
-
const lockedPolicy = isEssentialManagedApp(app);
|
|
115
|
-
const visibleJob = job === null || dismissedJobIds.has(job.id) ? null : job;
|
|
116
|
-
const visiblePolicyJobs = policyJobs.filter((policyJob) => !dismissedJobIds.has(policyJob.id));
|
|
117
|
-
|
|
118
|
-
return (
|
|
119
|
-
<article className={expanded ? "managed-app-card expanded" : "managed-app-card"}>
|
|
120
|
-
<button aria-expanded={expanded} className="managed-app-summary" data-tooltip={`${expanded ? "Collapse" : "Expand"} ${app.name} controls`} onClick={onToggle} type="button">
|
|
121
|
-
<span className="managed-app-title-block">
|
|
122
|
-
<span className="eyebrow">{app.category}</span>
|
|
123
|
-
<strong>{app.name}</strong>
|
|
124
|
-
<span>{app.description}</span>
|
|
125
|
-
</span>
|
|
126
|
-
<span className="managed-app-summary-meta">
|
|
127
|
-
<SummaryChip label="Installed" value={app.installed_version} />
|
|
128
|
-
<SummaryChip label="Latest" value={app.latest_version} />
|
|
129
|
-
<FiChevronDown aria-hidden="true" className="managed-app-chevron" />
|
|
130
|
-
</span>
|
|
131
|
-
</button>
|
|
132
|
-
{expanded ? <div className="managed-app-body">
|
|
133
|
-
<div className="managed-app-toolbar">
|
|
134
|
-
<div className="managed-app-links">
|
|
135
|
-
<OriginLink href={app.origin_website_url} label={text.website} />
|
|
136
|
-
<OriginLink href={app.origin_repo_url} label="Repo" />
|
|
137
|
-
</div>
|
|
138
|
-
<div className="managed-app-actions">
|
|
139
|
-
{app.actions.map((action) => <AppActionButton action={action.id} app={app} disabled={!action.enabled} key={action.id} onJob={onJob} commandPreview={action.command_preview} />)}
|
|
140
|
-
</div>
|
|
141
|
-
</div>
|
|
142
|
-
<div className="managed-app-details">
|
|
143
|
-
<ToggleSwitch checked={app.aidevops_install} disabled={lockedPolicy} label="setup installs" onChange={(value) => onPolicyToggle("setup", value)} />
|
|
144
|
-
<ToggleSwitch checked={app.aidevops_update} disabled={lockedPolicy} label="update maintains" onChange={(value) => onPolicyToggle("update", value)} />
|
|
145
|
-
{visibleJob ? <AppLogLink job={visibleJob} /> : null}
|
|
146
|
-
</div>
|
|
147
|
-
<AppMeta className="managed-app-path" label={text.path} value={app.install_path_ref} />
|
|
148
|
-
{visibleJob ? <AppActionTerminal job={visibleJob} onDismiss={onDismissJob} /> : null}
|
|
149
|
-
{visiblePolicyJobs.map((policyJob) => <AppActionTerminal job={policyJob} key={policyJob.id} onDismiss={onDismissJob} />)}
|
|
150
|
-
{visibleJob === null && visiblePolicyJobs.length === 0 ? <p className="empty-state compact-notice">No recent command output for this app. Run an action or change a policy toggle to open this app's terminal log.</p> : null}
|
|
151
|
-
</div> : null}
|
|
152
|
-
</article>
|
|
153
|
-
);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function SummaryChip({ label, value }: { label: string; value: string }): ReactElement {
|
|
157
|
-
const tooltip = `${label}: ${value}`;
|
|
158
|
-
return <span className="managed-summary-chip" data-tooltip={tooltip} title={tooltip}><small>{label}</small><span>{value}</span></span>;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function OriginLink({ href, label }: { href: string; label: string }): ReactElement {
|
|
162
|
-
if (href.length === 0) {
|
|
163
|
-
return <span className="origin-missing">{label}: source pending</span>;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
return <a data-tooltip={href} href={href} onClick={(event) => openExternalLink(event, href)} rel="noreferrer" target="_blank" title={href}>{label} <FiExternalLink aria-hidden="true" /></a>;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
function ToggleSwitch({ checked, disabled = false, label, onChange }: { checked: boolean; disabled?: boolean; label: string; onChange: (checked: boolean) => void }): ReactElement {
|
|
170
|
-
return <button aria-pressed={checked} className={checked ? "managed-toggle checked" : "managed-toggle"} data-tooltip={disabled ? "Essential aidevops component; policy is locked on" : undefined} disabled={disabled} onClick={() => onChange(!checked)} title={disabled ? "Essential aidevops component; policy is locked on" : undefined} type="button"><span className="managed-toggle-label">{label}</span><span aria-hidden="true" className={checked ? "switch-track checked" : "switch-track"}><span /></span></button>;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function AppMeta({ className = "", label, value }: { className?: string; label: string; value: string }): ReactElement {
|
|
174
|
-
return <span className={className.length > 0 ? `app-meta ${className}` : "app-meta"}><small>{label}</small><strong>{value}</strong></span>;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function AppLogLink({ job }: { job: GuiAppActionJobSummary }): ReactElement {
|
|
178
|
-
return <a className="app-meta app-log-link" href={`#app-job-${job.id}`}><small>Logs</small><strong>{job.action} · {job.status}</strong></a>;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
function AppActionButton({ action, app, commandPreview, disabled, onJob }: { action: GuiAppActionId; app: GuiManagedAppSummary; commandPreview: string; disabled: boolean; onJob: (job: GuiAppActionJobSummary) => void }): ReactElement {
|
|
182
|
-
const icon = action === "install" ? <FiDownload /> : action === "update" ? <FiRefreshCw /> : action === "reinstall" ? <FiRepeat /> : <FiTrash2 />;
|
|
183
|
-
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
184
|
-
|
|
185
|
-
async function runAction(): Promise<void> {
|
|
186
|
-
if (disabled) {
|
|
187
|
-
return;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
const response = await fetch(`/api/apps/${encodeURIComponent(app.id)}/actions/${action}`, { method: "POST" });
|
|
191
|
-
const envelope = await response.json() as GuiResponseEnvelope<GuiAppActionJobSummary>;
|
|
192
|
-
onJob(envelope.data);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
return <>
|
|
196
|
-
<button aria-label={`${action} ${app.name}`} className={action === "remove" ? "app-action-button remove" : "app-action-button"} data-tooltip={commandPreview} disabled={disabled} onClick={() => setConfirmOpen(true)} type="button">{icon}<span>{action}</span></button>
|
|
197
|
-
{confirmOpen ? <ConfirmActionModal action={action} app={app} commandPreview={commandPreview} close={() => setConfirmOpen(false)} confirm={() => { setConfirmOpen(false); void runAction(); }} /> : null}
|
|
198
|
-
</>;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function ConfirmActionModal({ action, app, close, commandPreview, confirm }: { action: GuiAppActionId; app: GuiManagedAppSummary; close: () => void; commandPreview: string; confirm: () => void }): ReactElement {
|
|
202
|
-
return (
|
|
203
|
-
<div className="modal-backdrop" role="presentation">
|
|
204
|
-
<section aria-modal="true" className="confirm-modal" role="dialog" aria-labelledby={`confirm-${app.id}-${action}`}>
|
|
205
|
-
<p className="eyebrow">Confirm action</p>
|
|
206
|
-
<h3 id={`confirm-${app.id}-${action}`}>{action} {app.name}?</h3>
|
|
207
|
-
<p>This will start the allowlisted background command for this app.</p>
|
|
208
|
-
<code>{commandPreview}</code>
|
|
209
|
-
<div className="confirm-modal-actions">
|
|
210
|
-
<button className="secondary-action" onClick={close} type="button">Cancel</button>
|
|
211
|
-
<button className={action === "remove" ? "app-action-button remove" : "app-action-button"} onClick={confirm} type="button">Confirm</button>
|
|
212
|
-
</div>
|
|
213
|
-
</section>
|
|
214
|
-
</div>
|
|
215
|
-
);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
function AppActionTerminal({ job, onDismiss }: { job: GuiAppActionJobSummary; onDismiss: (jobId: string) => void }): ReactElement {
|
|
219
|
-
const outputRef = useRef<HTMLPreElement | null>(null);
|
|
220
|
-
|
|
221
|
-
useEffect(() => {
|
|
222
|
-
if (outputRef.current !== null) {
|
|
223
|
-
outputRef.current.scrollTop = outputRef.current.scrollHeight;
|
|
224
|
-
}
|
|
225
|
-
}, [job.output.length]);
|
|
226
|
-
|
|
227
|
-
return (
|
|
228
|
-
<section className="app-terminal" id={`app-job-${job.id}`} aria-label="App action terminal output">
|
|
229
|
-
<header><strong>{job.app_id} {job.action}</strong><span>{terminalStatusLabel(job)}</span><button aria-label={`Dismiss ${job.app_id} ${job.action} terminal output`} className="terminal-close-button" onClick={() => onDismiss(job.id)} title="Dismiss terminal output" type="button"><FiX aria-hidden="true" /></button></header>
|
|
230
|
-
<pre ref={outputRef}>{renderTerminalOutput(job.output)}</pre>
|
|
231
|
-
</section>
|
|
232
|
-
);
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
function terminalStatusLabel(job: GuiAppActionJobSummary): string {
|
|
236
|
-
if (job.status === "running") {
|
|
237
|
-
return "running";
|
|
238
|
-
}
|
|
239
|
-
if (job.exit_code === null) {
|
|
240
|
-
return job.status;
|
|
241
|
-
}
|
|
242
|
-
return `${job.status} · exit ${job.exit_code}`;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
function renderTerminalOutput(lines: string[]): ReactNode[] {
|
|
246
|
-
return lines.flatMap((line, index) => [
|
|
247
|
-
...renderAnsiLine(line, `line-${index}`),
|
|
248
|
-
index === lines.length - 1 ? null : "\n",
|
|
249
|
-
]).filter((node): node is ReactNode => node !== null);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
function renderAnsiLine(line: string, keyPrefix: string): ReactNode[] {
|
|
253
|
-
const nodes: ReactNode[] = [];
|
|
254
|
-
const ansiPattern = new RegExp("\\x1b\\[([0-9;]*)m", "g");
|
|
255
|
-
let activeClass = "";
|
|
256
|
-
let lastIndex = 0;
|
|
257
|
-
let match = ansiPattern.exec(line);
|
|
258
|
-
|
|
259
|
-
while (match !== null) {
|
|
260
|
-
if (match.index > lastIndex) {
|
|
261
|
-
nodes.push(terminalSpan(line.slice(lastIndex, match.index), activeClass, `${keyPrefix}-${nodes.length}`));
|
|
262
|
-
}
|
|
263
|
-
activeClass = ansiClassForCodes(match[1]);
|
|
264
|
-
lastIndex = ansiPattern.lastIndex;
|
|
265
|
-
match = ansiPattern.exec(line);
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
if (lastIndex < line.length) {
|
|
269
|
-
nodes.push(terminalSpan(line.slice(lastIndex), activeClass, `${keyPrefix}-${nodes.length}`));
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
if (nodes.length === 0) {
|
|
273
|
-
nodes.push(terminalSpan(line, activeClass, `${keyPrefix}-0`));
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
return nodes;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
function terminalSpan(textValue: string, className: string, key: string): ReactNode {
|
|
280
|
-
const promptClass = textValue.startsWith("$ ") ? "ansi-prompt" : "";
|
|
281
|
-
const combinedClass = [className, promptClass].filter(Boolean).join(" ");
|
|
282
|
-
return combinedClass.length > 0 ? <span className={combinedClass} key={key}>{textValue}</span> : textValue;
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
function ansiClassForCodes(rawCodes: string): string {
|
|
286
|
-
const codes = rawCodes.length === 0 ? [0] : rawCodes.split(";").map((code) => Number.parseInt(code, 10));
|
|
287
|
-
const colorCode = [...codes].reverse().find((code) => (code >= 30 && code <= 37) || (code >= 90 && code <= 97));
|
|
288
|
-
if (colorCode === undefined) {
|
|
289
|
-
return "";
|
|
290
|
-
}
|
|
291
|
-
return `ansi-fg-${colorCode}`;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
105
|
async function refreshJob(jobId: string, setJobs: Dispatch<SetStateAction<Record<string, GuiAppActionJobSummary>>>): Promise<void> {
|
|
295
106
|
const response = await fetch(`/api/apps/jobs/${encodeURIComponent(jobId)}`);
|
|
296
107
|
if (!response.ok) {
|
|
@@ -320,34 +131,23 @@ function sortedManagedApps(apps: GuiManagedAppSummary[]): GuiManagedAppSummary[]
|
|
|
320
131
|
}
|
|
321
132
|
|
|
322
133
|
function managedCategoryForApp(app: GuiManagedAppSummary): ManagedCategoryId {
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
}
|
|
326
|
-
if (app.category.includes("cli")) {
|
|
327
|
-
return "cli";
|
|
328
|
-
}
|
|
329
|
-
if (app.category.includes("ai")) {
|
|
330
|
-
return "ai";
|
|
331
|
-
}
|
|
332
|
-
if (["desktop", "editor"].includes(app.category)) {
|
|
333
|
-
return "desktop";
|
|
334
|
-
}
|
|
335
|
-
if (["terminal"].includes(app.category) || app.name.toLowerCase().includes("terminal")) {
|
|
336
|
-
return "terminal";
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
return "cli";
|
|
134
|
+
const exactCategory = managedCategoryByExactName[app.category];
|
|
135
|
+
return exactCategory ?? managedCategoryByKeyword(app);
|
|
340
136
|
}
|
|
341
137
|
|
|
342
|
-
function
|
|
343
|
-
|
|
138
|
+
function managedCategoryByKeyword(app: GuiManagedAppSummary): ManagedCategoryId {
|
|
139
|
+
const loweredName = app.name.toLowerCase();
|
|
140
|
+
const categoryRules: Array<{ matches: boolean; category: ManagedCategoryId }> = [
|
|
141
|
+
{ matches: app.category.includes("cli"), category: "cli" },
|
|
142
|
+
{ matches: app.category.includes("ai"), category: "ai" },
|
|
143
|
+
{ matches: loweredName.includes("terminal"), category: "terminal" },
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
return categoryRules.find((rule) => rule.matches)?.category ?? "cli";
|
|
344
147
|
}
|
|
345
148
|
|
|
346
149
|
type AppCollectionId = "aidevops" | "recommended";
|
|
347
150
|
type ManagedCategoryId = "core" | "ai" | "cli" | "desktop" | "terminal";
|
|
348
|
-
type RecommendedOsId = "all" | "macos" | "linux" | "windows" | "ios" | "android";
|
|
349
|
-
type RecommendedPlatformId = "webapp" | "saas" | "cli" | "api";
|
|
350
|
-
type RecommendedPlatformFilterId = "all" | RecommendedPlatformId;
|
|
351
151
|
type ManagedPolicyId = "setup" | "update";
|
|
352
152
|
type ManagedPolicyToggleState = Record<string, Partial<Record<ManagedPolicyId, boolean>>>;
|
|
353
153
|
|
|
@@ -356,25 +156,20 @@ interface TabOption<T extends string> {
|
|
|
356
156
|
label: string;
|
|
357
157
|
}
|
|
358
158
|
|
|
359
|
-
interface RecommendedApp {
|
|
360
|
-
name: string;
|
|
361
|
-
description: string;
|
|
362
|
-
websiteUrl: string;
|
|
363
|
-
alternativeToUrl?: string;
|
|
364
|
-
repoUrl?: string;
|
|
365
|
-
iosUrl?: string;
|
|
366
|
-
androidUrl?: string;
|
|
367
|
-
fdroidUrl?: string;
|
|
368
|
-
obtainiumUrl?: string;
|
|
369
|
-
os: RecommendedOsId[];
|
|
370
|
-
platforms: RecommendedPlatformId[];
|
|
371
|
-
}
|
|
372
|
-
|
|
373
159
|
const appCollectionTabs: TabOption<AppCollectionId>[] = [
|
|
374
160
|
{ id: "aidevops", label: "AIDevOps" },
|
|
375
161
|
{ id: "recommended", label: "Recommended" },
|
|
376
162
|
];
|
|
377
163
|
|
|
164
|
+
const managedCategoryByExactName: Record<string, ManagedCategoryId> = {
|
|
165
|
+
automation: "core",
|
|
166
|
+
core: "core",
|
|
167
|
+
desktop: "desktop",
|
|
168
|
+
editor: "desktop",
|
|
169
|
+
safety: "core",
|
|
170
|
+
terminal: "terminal",
|
|
171
|
+
};
|
|
172
|
+
|
|
378
173
|
const managedCategoryTabs: TabOption<ManagedCategoryId>[] = [
|
|
379
174
|
{ id: "core", label: "Core" },
|
|
380
175
|
{ id: "ai", label: "AI" },
|
|
@@ -383,166 +178,10 @@ const managedCategoryTabs: TabOption<ManagedCategoryId>[] = [
|
|
|
383
178
|
{ id: "terminal", label: "Terminal" },
|
|
384
179
|
];
|
|
385
180
|
|
|
386
|
-
const recommendedPlatformTabs: TabOption<RecommendedPlatformFilterId>[] = [
|
|
387
|
-
{ id: "all", label: "All" },
|
|
388
|
-
{ id: "webapp", label: "WebApp" },
|
|
389
|
-
{ id: "saas", label: "SaaS" },
|
|
390
|
-
{ id: "cli", label: "CLI" },
|
|
391
|
-
{ id: "api", label: "API" },
|
|
392
|
-
];
|
|
393
|
-
|
|
394
|
-
const recommendedOsTabs: TabOption<RecommendedOsId>[] = [
|
|
395
|
-
{ id: "all", label: "All OS" },
|
|
396
|
-
{ id: "macos", label: "macOS" },
|
|
397
|
-
{ id: "linux", label: "Linux" },
|
|
398
|
-
{ id: "windows", label: "Windows" },
|
|
399
|
-
{ id: "ios", label: "iOS" },
|
|
400
|
-
{ id: "android", label: "Android" },
|
|
401
|
-
];
|
|
402
|
-
|
|
403
|
-
const recommendedApps = ([
|
|
404
|
-
{ name: "Affinity Studio", description: "Creative design suite.", websiteUrl: "https://www.affinity.studio/", alternativeToUrl: "https://alternativeto.net/software/affinity-1/", os: ["macos", "windows"], platforms: [] },
|
|
405
|
-
{ name: "Bitwarden", description: "Open source password manager.", websiteUrl: "https://bitwarden.com", alternativeToUrl: "https://alternativeto.net/software/bitwarden--free-password-manager/", repoUrl: "https://github.com/bitwarden", iosUrl: "https://itunes.apple.com/app/bitwarden-free-password-manager/id1137397744?mt=8", androidUrl: "https://play.google.com/store/apps/details?id=com.x8bit.bitwarden", fdroidUrl: "https://mobileapp.bitwarden.com/fdroid/repo", os: ["macos", "linux", "windows", "ios", "android"], platforms: ["webapp", "saas", "cli", "api"] },
|
|
406
|
-
{ name: "Brave Browser", description: "Privacy-focused web browser.", websiteUrl: "https://brave.com/", alternativeToUrl: "https://alternativeto.net/software/brave/", iosUrl: "https://apps.apple.com/app/brave-private-web-browser-vpn/id1052879175?mt=8", androidUrl: "https://play.google.com/store/apps/details?id=com.brave.browser", os: ["macos", "linux", "windows", "ios", "android"], platforms: [] },
|
|
407
|
-
{ name: "Cloudron", description: "Self-hosted app platform.", websiteUrl: "https://www.cloudron.io/", alternativeToUrl: "https://alternativeto.net/software/cloudron/", os: ["linux"], platforms: ["webapp", "cli", "api"] },
|
|
408
|
-
{ name: "Collabora Online", description: "Online office collaboration.", websiteUrl: "https://www.collaboraonline.com/collabora-online/", alternativeToUrl: "https://alternativeto.net/software/collabora-online/", os: ["linux"], platforms: ["webapp", "api"] },
|
|
409
|
-
{ name: "Cometly", description: "Marketing attribution analytics.", websiteUrl: "https://www.cometly.com/", os: [], platforms: ["saas", "api"] },
|
|
410
|
-
{ name: "DaVinci Resolve", description: "Professional video editing, color, VFX, and audio post-production.", websiteUrl: "https://www.blackmagicdesign.com/products/davinciresolve/", alternativeToUrl: "https://alternativeto.net/software/davinci-resolve/", os: ["macos", "linux", "windows"], platforms: [] },
|
|
411
|
-
{ name: "DocuSeal", description: "Open source document signing.", websiteUrl: "https://www.docuseal.com/", alternativeToUrl: "https://alternativeto.net/software/docuseal/", os: ["linux"], platforms: ["webapp", "saas", "api"] },
|
|
412
|
-
{ name: "Element", description: "Matrix messaging client.", websiteUrl: "https://element.io/", alternativeToUrl: "https://alternativeto.net/software/element-app/", iosUrl: "https://apps.apple.com/app/id1631335820", androidUrl: "https://play.google.com/store/apps/details?id=io.element.android.x", fdroidUrl: "https://f-droid.org/en/packages/io.element.android.x/", os: ["macos", "linux", "windows", "ios", "android"], platforms: ["webapp"] },
|
|
413
|
-
{ name: "Enpass", description: "Offline-first password manager.", websiteUrl: "https://www.enpass.io/", alternativeToUrl: "https://alternativeto.net/software/enpass/", iosUrl: "https://apps.apple.com/app/apple-store/id455566716?pt=637991", androidUrl: "https://play.google.com/store/apps/details?id=io.enpass.app", os: ["macos", "linux", "windows", "ios", "android"], platforms: [] },
|
|
414
|
-
{ name: "EspoCRM", description: "Open source CRM.", websiteUrl: "https://www.espocrm.com/", alternativeToUrl: "https://alternativeto.net/software/espocrm/", os: ["linux"], platforms: ["webapp", "saas", "api"] },
|
|
415
|
-
{ name: "Fathom Analytics", description: "Privacy-first analytics.", websiteUrl: "https://usefathom.com/", alternativeToUrl: "https://alternativeto.net/software/fathom-analytics/", os: [], platforms: ["saas", "api"] },
|
|
416
|
-
{ name: "FontBase", description: "Font management and reference tool.", websiteUrl: "https://fontba.se/", alternativeToUrl: "https://alternativeto.net/software/fontbase/", os: ["macos"], platforms: [] },
|
|
417
|
-
{ name: "Forgejo", description: "Self-hosted Git forge.", websiteUrl: "https://forgejo.org/", alternativeToUrl: "https://alternativeto.net/software/forgejo/", os: ["linux"], platforms: ["webapp", "cli", "api"] },
|
|
418
|
-
{ name: "Ghost", description: "Publishing platform.", websiteUrl: "https://ghost.org/", alternativeToUrl: "https://alternativeto.net/software/ghost/", os: ["linux"], platforms: ["webapp", "saas", "cli", "api"] },
|
|
419
|
-
{ name: "Gitea", description: "Self-hosted Git service.", websiteUrl: "https://about.gitea.com/", alternativeToUrl: "https://alternativeto.net/software/gitea/", os: ["linux", "windows"], platforms: ["webapp", "cli", "api"] },
|
|
420
|
-
{ name: "GitLab", description: "DevSecOps and Git hosting platform.", websiteUrl: "https://gitlab.com/", alternativeToUrl: "https://alternativeto.net/software/gitlab/", os: ["linux"], platforms: ["webapp", "saas", "cli", "api"] },
|
|
421
|
-
{ name: "LibreOffice", description: "Open source office suite.", websiteUrl: "https://www.libreoffice.org/", alternativeToUrl: "https://alternativeto.net/software/libreoffice/", os: ["macos", "linux", "windows"], platforms: [] },
|
|
422
|
-
{ name: "LocalWP", description: "Local WordPress development.", websiteUrl: "https://localwp.com/", alternativeToUrl: "https://alternativeto.net/software/local-by-flywheel/", os: ["macos", "linux", "windows"], platforms: [] },
|
|
423
|
-
{ name: "Matomo", description: "Open analytics platform.", websiteUrl: "https://matomo.org/", alternativeToUrl: "https://alternativeto.net/software/piwik/", os: ["linux"], platforms: ["webapp", "saas", "api"] },
|
|
424
|
-
{ name: "Nextcloud", description: "Open source content collaboration platform.", websiteUrl: "https://nextcloud.com/", alternativeToUrl: "https://alternativeto.net/software/nextcloud/", repoUrl: "https://github.com/nextcloud", iosUrl: "https://itunes.apple.com/us/app/nextcloud/id1125420102?mt=8", androidUrl: "https://play.google.com/store/apps/details?id=com.nextcloud.client", fdroidUrl: "https://f-droid.org/packages/com.nextcloud.client/", os: ["macos", "linux", "windows", "ios", "android"], platforms: ["webapp", "saas", "api"] },
|
|
425
|
-
{ name: "Nextcloud Talk", description: "Open source video calls, chat, and collaboration for Nextcloud.", websiteUrl: "https://nextcloud.com/talk/", repoUrl: "https://github.com/nextcloud/spreed", iosUrl: "https://apps.apple.com/us/app/nextcloud-talk/id1296825574", androidUrl: "https://play.google.com/store/apps/details?id=com.nextcloud.talk2&hl=en", os: ["ios", "android"], platforms: ["webapp"] },
|
|
426
|
-
{ name: "OBS Studio", description: "Open source video recording and live streaming.", websiteUrl: "https://obsproject.com/", alternativeToUrl: "https://alternativeto.net/software/open-broadcaster-software/", repoUrl: "https://github.com/obsproject/obs-studio", os: ["macos", "linux", "windows"], platforms: [] },
|
|
427
|
-
{ name: "ONLYOFFICE", description: "Office and document collaboration.", websiteUrl: "https://www.onlyoffice.com/", alternativeToUrl: "https://alternativeto.net/software/onlyoffice/", repoUrl: "https://github.com/ONLYOFFICE/", iosUrl: "https://apps.apple.com/us/app/onlyoffice-documents/id944896972", androidUrl: "https://play.google.com/store/apps/details?id=com.onlyoffice.documents", os: ["macos", "linux", "windows", "ios", "android"], platforms: ["webapp", "saas", "api"] },
|
|
428
|
-
{ name: "OpenScreen", description: "Open screen-sharing project.", websiteUrl: "https://github.com/getopenscreen/openscreen/releases", alternativeToUrl: "https://alternativeto.net/software/openscreen/", repoUrl: "https://github.com/getopenscreen/openscreen", os: ["macos", "linux", "windows"], platforms: [] },
|
|
429
|
-
{ name: "Osaurus", description: "AI app workspace.", websiteUrl: "https://osaurus.ai/", alternativeToUrl: "https://alternativeto.net/software/osaurus/", os: ["macos"], platforms: [] },
|
|
430
|
-
{ name: "Parallels Desktop", description: "Run Windows, Linux, and virtual machines on macOS.", websiteUrl: "https://www.parallels.com/products/desktop/", alternativeToUrl: "https://alternativeto.net/software/parallels-desktop/about/", os: ["macos"], platforms: [] },
|
|
431
|
-
{ name: "PDF Studio", description: "PDF editor.", websiteUrl: "https://www.qoppa.com/pdfstudio/", alternativeToUrl: "https://alternativeto.net/software/qoppa-pdf-studio/", os: ["macos", "linux", "windows"], platforms: [] },
|
|
432
|
-
{ name: "Pixelmator Pro", description: "Professional image editor for macOS.", websiteUrl: "https://www.apple.com/pixelmator-pro/", os: ["macos"], platforms: [] },
|
|
433
|
-
{ name: "PostHog", description: "Product analytics platform.", websiteUrl: "https://posthog.com/", alternativeToUrl: "https://alternativeto.net/software/posthog/", os: ["linux"], platforms: ["webapp", "saas", "cli", "api"] },
|
|
434
|
-
{ name: "Postiz", description: "Social media scheduling.", websiteUrl: "https://postiz.com/", alternativeToUrl: "https://alternativeto.net/software/postiz/", os: ["linux"], platforms: ["webapp", "saas", "api"] },
|
|
435
|
-
{ name: "PrivateBin", description: "Zero-knowledge pastebin.", websiteUrl: "https://privatebin.info/", alternativeToUrl: "https://alternativeto.net/software/privatebin/", repoUrl: "https://github.com/PrivateBin/PrivateBin", os: ["linux"], platforms: ["webapp", "api"] },
|
|
436
|
-
{ name: "Proxmox", description: "Virtualization platform.", websiteUrl: "https://www.proxmox.com/", alternativeToUrl: "https://alternativeto.net/software/proxmox-virtual-environment/", os: ["linux"], platforms: ["webapp", "cli", "api"] },
|
|
437
|
-
{ name: "QuickFile", description: "Accounting platform.", websiteUrl: "https://www.quickfile.co.uk/", os: [], platforms: ["saas", "api"] },
|
|
438
|
-
{ name: "Reframed", description: "Creative framing utility.", websiteUrl: "https://www.reframed.dev/", alternativeToUrl: "https://alternativeto.net/software/reframed/", os: ["macos"], platforms: [] },
|
|
439
|
-
{ name: "Rybbit", description: "Web analytics.", websiteUrl: "https://rybbit.com/", alternativeToUrl: "https://alternativeto.net/software/rybbit/", os: ["linux"], platforms: ["webapp", "saas", "api"] },
|
|
440
|
-
{ name: "SchildiChat", description: "Matrix messaging client.", websiteUrl: "https://schildi.chat/", alternativeToUrl: "https://alternativeto.net/software/schildichat/", androidUrl: "https://play.google.com/store/apps/details?id=chat.schildi.android", fdroidUrl: "https://schildi.chat/next/install-from-sc-fdroid", os: ["macos", "linux", "windows", "android"], platforms: ["webapp"] },
|
|
441
|
-
{ name: "ScreenFlow", description: "Screen recording and editing.", websiteUrl: "https://www.telestream.net/screenflow/", alternativeToUrl: "https://alternativeto.net/software/screenflow/", os: ["macos"], platforms: [] },
|
|
442
|
-
{ name: "SEO Utils", description: "Desktop SEO tools.", websiteUrl: "https://seoutils.app/", os: ["macos", "linux", "windows"], platforms: [] },
|
|
443
|
-
{ name: "Shottr", description: "macOS screenshot utility.", websiteUrl: "https://shottr.cc/", alternativeToUrl: "https://alternativeto.net/software/shottr/", os: ["macos"], platforms: [] },
|
|
444
|
-
{ name: "Signal", description: "Private messenger.", websiteUrl: "https://signal.org/", alternativeToUrl: "https://alternativeto.net/software/signal-private-messenger/", iosUrl: "https://apps.apple.com/us/app/signal-private-messenger/id874139669", androidUrl: "https://play.google.com/store/apps/details?id=org.thoughtcrime.securesms", os: ["macos", "linux", "windows", "ios", "android"], platforms: [] },
|
|
445
|
-
{ name: "SimpleX Chat", description: "Private messenger with no user IDs.", websiteUrl: "https://simplex.chat/", alternativeToUrl: "https://alternativeto.net/software/simplex-chat/about/", repoUrl: "https://github.com/simplex-chat", iosUrl: "https://apps.apple.com/us/app/simplex-chat/id1605771084", androidUrl: "https://play.google.com/store/apps/details?id=chat.simplex.app", fdroidUrl: "https://simplex.chat/fdroid", os: ["macos", "linux", "windows", "ios", "android"], platforms: ["cli"] },
|
|
446
|
-
{ name: "Telegram", description: "Cloud-based mobile and desktop messaging app.", websiteUrl: "https://telegram.org/", iosUrl: "https://apps.apple.com/us/app/telegram-messenger/id686449807", androidUrl: "https://play.google.com/store/apps/details?id=org.telegram.messenger", os: ["macos", "linux", "windows", "ios", "android"], platforms: ["webapp", "api"] },
|
|
447
|
-
{ name: "Thunderbird", description: "Email and calendar app.", websiteUrl: "https://www.thunderbird.net/", alternativeToUrl: "https://alternativeto.net/software/mozilla-thunderbird/about/", os: ["macos", "linux", "windows"], platforms: [] },
|
|
448
|
-
{ name: "Ubicloud", description: "Open cloud platform.", websiteUrl: "https://www.ubicloud.com/", alternativeToUrl: "https://alternativeto.net/software/ubicloud/about/", repoUrl: "https://github.com/ubicloud/ubicloud", os: ["linux"], platforms: ["webapp", "saas", "cli", "api"] },
|
|
449
|
-
{ name: "Vaultwarden", description: "Alternative Bitwarden server implementation.", websiteUrl: "https://github.com/dani-garcia/vaultwarden", alternativeToUrl: "https://alternativeto.net/software/vaultwarden/", repoUrl: "https://github.com/dani-garcia/vaultwarden", os: ["linux"], platforms: ["webapp", "api"] },
|
|
450
|
-
{ name: "VideoProc", description: "Video processing toolkit.", websiteUrl: "https://www.videoproc.com/", alternativeToUrl: "https://alternativeto.net/software/videoproc/about/", os: ["macos", "windows"], platforms: [] },
|
|
451
|
-
{ name: "VirtualBox", description: "Virtualization app.", websiteUrl: "https://www.virtualbox.org/", alternativeToUrl: "https://alternativeto.net/software/virtualbox/about/", os: ["macos", "linux", "windows"], platforms: ["cli"] },
|
|
452
|
-
{ name: "WordPress", description: "Open source publishing platform.", websiteUrl: "https://wordpress.org/", alternativeToUrl: "https://alternativeto.net/software/wordpress/about/", os: ["linux"], platforms: ["webapp", "saas", "cli", "api"] },
|
|
453
|
-
] satisfies RecommendedApp[]).sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: "base" }));
|
|
454
|
-
|
|
455
181
|
function TabNav<T extends string>({ label, onChange, tabs, value }: { label: string; onChange: (value: T) => void; tabs: TabOption<T>[]; value: T }): ReactElement {
|
|
456
182
|
return <div aria-label={label} className="pill-tabs app-subnav" role="tablist">{tabs.map((tab) => <button aria-selected={value === tab.id} className={value === tab.id ? "active" : ""} key={tab.id} onClick={() => onChange(tab.id)} role="tab" type="button">{tab.label}</button>)}</div>;
|
|
457
183
|
}
|
|
458
184
|
|
|
459
|
-
function RecommendedAppsSurface({ apps, os, platform, setOs, setPlatform }: { apps: RecommendedApp[]; os: RecommendedOsId; platform: RecommendedPlatformFilterId; setOs: (os: RecommendedOsId) => void; setPlatform: (platform: RecommendedPlatformFilterId) => void }): ReactElement {
|
|
460
|
-
return <>
|
|
461
|
-
<div className="recommended-filter-tabs">
|
|
462
|
-
<TabNav label="Recommended app operating system filters" tabs={recommendedOsTabs} value={os} onChange={setOs} />
|
|
463
|
-
<TabNav label="Recommended app platform filters" tabs={recommendedPlatformTabs} value={platform} onChange={setPlatform} />
|
|
464
|
-
</div>
|
|
465
|
-
<div className="recommended-app-grid">
|
|
466
|
-
{apps.map((app) => <RecommendedAppCard app={app} key={app.name} setOs={setOs} setPlatform={setPlatform} />)}
|
|
467
|
-
</div>
|
|
468
|
-
</>;
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
function RecommendedAppCard({ app, setOs, setPlatform }: { app: RecommendedApp; setOs: (os: RecommendedOsId) => void; setPlatform: (platform: RecommendedPlatformFilterId) => void }): ReactElement {
|
|
472
|
-
return <article className="recommended-app-card">
|
|
473
|
-
<div>
|
|
474
|
-
<strong>{app.name}</strong>
|
|
475
|
-
<p>{app.description}</p>
|
|
476
|
-
</div>
|
|
477
|
-
<div className="app-icon-groups">
|
|
478
|
-
<PlatformIconList platforms={app.platforms} setPlatform={setPlatform} />
|
|
479
|
-
<OsIconList os={app.os} setOs={setOs} />
|
|
480
|
-
</div>
|
|
481
|
-
<div className="managed-app-links plain-links">
|
|
482
|
-
<OriginLink href={app.websiteUrl} label={text.website} />
|
|
483
|
-
{app.alternativeToUrl ? <OriginLink href={app.alternativeToUrl} label="AlternativeTo" /> : null}
|
|
484
|
-
{app.repoUrl ? <OriginLink href={app.repoUrl} label="Repo" /> : null}
|
|
485
|
-
{app.iosUrl ? <OriginLink href={app.iosUrl} label="iOS" /> : null}
|
|
486
|
-
{app.androidUrl ? <OriginLink href={app.androidUrl} label="Android" /> : null}
|
|
487
|
-
{app.fdroidUrl ? <OriginLink href={app.fdroidUrl} label="F-Droid" /> : null}
|
|
488
|
-
{app.obtainiumUrl ? <OriginLink href={app.obtainiumUrl} label="Obtainium" /> : null}
|
|
489
|
-
</div>
|
|
490
|
-
</article>;
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
function PlatformIconList({ platforms, setPlatform }: { platforms: RecommendedPlatformId[]; setPlatform: (platform: RecommendedPlatformFilterId) => void }): ReactElement | null {
|
|
494
|
-
if (platforms.length === 0) {
|
|
495
|
-
return null;
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
return <span className="os-icon-list platform-icon-list">{platforms.map((platform) => <PlatformIcon id={platform} key={platform} setPlatform={setPlatform} />)}</span>;
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
function PlatformIcon({ id, setPlatform }: { id: RecommendedPlatformId; setPlatform: (platform: RecommendedPlatformFilterId) => void }): ReactElement {
|
|
502
|
-
const iconMap: Record<RecommendedPlatformId, { Icon: IconType; label: string }> = {
|
|
503
|
-
webapp: { Icon: FiMonitor, label: "WebApp" },
|
|
504
|
-
saas: { Icon: FiGlobe, label: "SaaS" },
|
|
505
|
-
cli: { Icon: FiTerminal, label: "CLI" },
|
|
506
|
-
api: { Icon: FiCode, label: "API" },
|
|
507
|
-
};
|
|
508
|
-
const { Icon, label } = iconMap[id];
|
|
509
|
-
|
|
510
|
-
return <button aria-label={`Filter by ${label}`} data-tooltip={`Filter by ${label}`} onClick={() => setPlatform(id)} title={`Filter by ${label}`} type="button"><Icon aria-hidden="true" focusable="false" /></button>;
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
function OsIconList({ os, setOs }: { os: RecommendedOsId[]; setOs: (os: RecommendedOsId) => void }): ReactElement | null {
|
|
514
|
-
if (os.length === 0) {
|
|
515
|
-
return null;
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
return <span className="os-icon-list">{os.map((item) => <OsIcon id={item} key={item} setOs={setOs} />)}</span>;
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
function OsIcon({ id, setOs }: { id: RecommendedOsId; setOs: (os: RecommendedOsId) => void }): ReactElement {
|
|
522
|
-
const iconMap: Record<RecommendedOsId, { Icon: IconType; label: string }> = {
|
|
523
|
-
all: { Icon: FiGlobe, label: "Web app" },
|
|
524
|
-
macos: { Icon: FaApple, label: "macOS" },
|
|
525
|
-
linux: { Icon: FaLinux, label: "Linux" },
|
|
526
|
-
windows: { Icon: FaWindows, label: "Windows" },
|
|
527
|
-
ios: { Icon: SiIos, label: "iOS" },
|
|
528
|
-
android: { Icon: IoLogoAndroid, label: "Android" },
|
|
529
|
-
};
|
|
530
|
-
const { Icon, label } = iconMap[id];
|
|
531
|
-
|
|
532
|
-
return <button aria-label={`Filter by ${label}`} data-tooltip={`Filter by ${label}`} onClick={() => setOs(id)} title={`Filter by ${label}`} type="button"><Icon aria-hidden="true" focusable="false" /></button>;
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
function recommendedAppMatchesFilters(app: RecommendedApp, platform: RecommendedPlatformFilterId, os: RecommendedOsId): boolean {
|
|
536
|
-
const platformMatches = platform === "all" || app.platforms.includes(platform);
|
|
537
|
-
const osMatches = os === "all" || app.os.includes(os);
|
|
538
|
-
|
|
539
|
-
return platformMatches && osMatches;
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
export function nextRecommendedFilterValue<T extends string>(current: T, next: T, defaultValue: T): T {
|
|
543
|
-
return current === next && next !== defaultValue ? defaultValue : next;
|
|
544
|
-
}
|
|
545
|
-
|
|
546
185
|
function applyManagedPolicyToggles(app: GuiManagedAppSummary, policy: Partial<Record<ManagedPolicyId, boolean>> | undefined): GuiManagedAppSummary {
|
|
547
186
|
if (policy === undefined) {
|
|
548
187
|
return app;
|
|
@@ -642,113 +281,3 @@ function AppTooltip({ tooltip }: { tooltip: AppTooltipState }): ReactElement {
|
|
|
642
281
|
|
|
643
282
|
return <div className="app-global-tooltip" role="tooltip" style={style}>{tooltip.text}</div>;
|
|
644
283
|
}
|
|
645
|
-
|
|
646
|
-
function openExternalLink(event: ReactMouseEvent<HTMLAnchorElement>, href: string): void {
|
|
647
|
-
event.stopPropagation();
|
|
648
|
-
if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
|
|
649
|
-
return;
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
event.preventDefault();
|
|
653
|
-
if (document.documentElement.dataset.desktopShell === "macos") {
|
|
654
|
-
window.location.assign(href);
|
|
655
|
-
return;
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
const opened = window.open(href, "_blank", "noopener,noreferrer");
|
|
659
|
-
if (opened !== null) {
|
|
660
|
-
opened.opener = null;
|
|
661
|
-
return;
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
window.location.assign(href);
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
export function InstallationSurface(): ReactElement {
|
|
668
|
-
return (
|
|
669
|
-
<section className="panel" aria-label={text.installation}>
|
|
670
|
-
<div className="section-heading">
|
|
671
|
-
<p className="eyebrow">{text.setup}</p>
|
|
672
|
-
<h2>{text.installation}</h2>
|
|
673
|
-
<p>{text.installationIntro}</p>
|
|
674
|
-
</div>
|
|
675
|
-
<div className="installation-list">
|
|
676
|
-
{installationRows.map((row) => (
|
|
677
|
-
<article className="install-row" key={row.name}>
|
|
678
|
-
<div><strong>{row.name}</strong><small>{row.scope}</small></div>
|
|
679
|
-
<TogglePill checked={row.install} label={text.install} />
|
|
680
|
-
<TogglePill checked={row.update} label={text.update} />
|
|
681
|
-
</article>
|
|
682
|
-
))}
|
|
683
|
-
</div>
|
|
684
|
-
<p className="empty-state">{text.plannedNotice}</p>
|
|
685
|
-
</section>
|
|
686
|
-
);
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
export function EditableInventorySurface({ columns, initialRows, intro, title }: {
|
|
690
|
-
columns: InventoryColumn[];
|
|
691
|
-
initialRows: Record<string, string>[];
|
|
692
|
-
intro: string;
|
|
693
|
-
title: string;
|
|
694
|
-
}): ReactElement {
|
|
695
|
-
const [draftRows, setDraftRows] = useState(() => initialRows.map((row) => createDraftRow(row)));
|
|
696
|
-
|
|
697
|
-
function updateDraftRow(rowId: string, key: string, value: string): void {
|
|
698
|
-
setDraftRows((currentRows) => currentRows.map((row) => row.id === rowId ? { ...row, values: { ...row.values, [key]: value } } : row));
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
function addDraftRow(): void {
|
|
702
|
-
setDraftRows((currentRows) => [...currentRows, createDraftRow(emptyRow(columns))]);
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
return (
|
|
706
|
-
<section className="panel" aria-label={title}>
|
|
707
|
-
<div className="section-heading split-heading">
|
|
708
|
-
<div>
|
|
709
|
-
<p className="eyebrow">{text.infrastructure}</p>
|
|
710
|
-
<h2>{title}</h2>
|
|
711
|
-
<p>{intro}</p>
|
|
712
|
-
</div>
|
|
713
|
-
<button className="secondary-action" onClick={addDraftRow} type="button">{text.addRow}</button>
|
|
714
|
-
</div>
|
|
715
|
-
<p className="notice compact-notice">{text.draftOnly}</p>
|
|
716
|
-
<div className="editable-table">
|
|
717
|
-
<div className="editable-row header-row">
|
|
718
|
-
{columns.map((column) => <span key={column.key}>{column.label}</span>)}
|
|
719
|
-
</div>
|
|
720
|
-
{draftRows.map((row) => (
|
|
721
|
-
<div className="editable-row" key={row.id}>
|
|
722
|
-
{columns.map((column) => (
|
|
723
|
-
<input
|
|
724
|
-
aria-label={`${title} ${column.label}`}
|
|
725
|
-
key={column.key}
|
|
726
|
-
onChange={(event) => updateDraftRow(row.id, column.key, event.currentTarget.value)}
|
|
727
|
-
placeholder={column.label}
|
|
728
|
-
value={row.values[column.key] ?? ""}
|
|
729
|
-
/>
|
|
730
|
-
))}
|
|
731
|
-
</div>
|
|
732
|
-
))}
|
|
733
|
-
</div>
|
|
734
|
-
</section>
|
|
735
|
-
);
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
function TogglePill({ checked, label }: { checked: boolean; label: string }): ReactElement {
|
|
739
|
-
return <span className={checked ? "toggle-pill checked" : "toggle-pill"}>{label}</span>;
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
function createDraftRow(values: Record<string, string>): DraftInventoryRow {
|
|
743
|
-
draftRowCounter += 1;
|
|
744
|
-
return { id: `draft-row-${draftRowCounter}`, values };
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
function emptyRow(columns: InventoryColumn[]): Record<string, string> {
|
|
748
|
-
const row: Record<string, string> = {};
|
|
749
|
-
for (const column of columns) {
|
|
750
|
-
row[column.key] = "";
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
return row;
|
|
754
|
-
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { GuiAppActionId, GuiAppActionJobSummary, GuiManagedAppSummary, GuiResponseEnvelope } from "@aidevops/gui-shared";
|
|
2
|
+
import { type MouseEvent as ReactMouseEvent, type ReactElement, useState } from "react";
|
|
3
|
+
import { FiChevronDown, FiDownload, FiExternalLink, FiRefreshCw, FiRepeat, FiTrash2 } from "react-icons/fi";
|
|
4
|
+
import { AppActionTerminal } from "./AppActionTerminal";
|
|
5
|
+
import { text } from "./app-model";
|
|
6
|
+
|
|
7
|
+
type ManagedPolicyId = "setup" | "update";
|
|
8
|
+
|
|
9
|
+
export function ManagedAppPanel({ app, dismissedJobIds, expanded, job, onDismissJob, onJob, onPolicyToggle, onToggle, policyJobs }: { app: GuiManagedAppSummary; dismissedJobIds: Set<string>; expanded: boolean; job: GuiAppActionJobSummary | null; onDismissJob: (jobId: string) => void; onJob: (job: GuiAppActionJobSummary) => void; onPolicyToggle: (policy: ManagedPolicyId, value: boolean) => void; onToggle: () => void; policyJobs: GuiAppActionJobSummary[] }): ReactElement {
|
|
10
|
+
const lockedPolicy = isEssentialManagedApp(app);
|
|
11
|
+
const visibleJob = job === null || dismissedJobIds.has(job.id) ? null : job;
|
|
12
|
+
const visiblePolicyJobs = policyJobs.filter((policyJob) => !dismissedJobIds.has(policyJob.id));
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<article className={expanded ? "managed-app-card expanded" : "managed-app-card"}>
|
|
16
|
+
<button aria-expanded={expanded} className="managed-app-summary" data-tooltip={`${expanded ? "Collapse" : "Expand"} ${app.name} controls`} onClick={onToggle} type="button">
|
|
17
|
+
<span className="managed-app-title-block">
|
|
18
|
+
<span className="eyebrow">{app.category}</span>
|
|
19
|
+
<strong>{app.name}</strong>
|
|
20
|
+
<span>{app.description}</span>
|
|
21
|
+
</span>
|
|
22
|
+
<span className="managed-app-summary-meta">
|
|
23
|
+
<SummaryChip label="Installed" value={app.installed_version} />
|
|
24
|
+
<SummaryChip label="Latest" value={app.latest_version} />
|
|
25
|
+
<FiChevronDown aria-hidden="true" className="managed-app-chevron" />
|
|
26
|
+
</span>
|
|
27
|
+
</button>
|
|
28
|
+
{expanded ? <div className="managed-app-body">
|
|
29
|
+
<div className="managed-app-toolbar">
|
|
30
|
+
<div className="managed-app-links">
|
|
31
|
+
<OriginLink href={app.origin_website_url} label={text.website} />
|
|
32
|
+
<OriginLink href={app.origin_repo_url} label="Repo" />
|
|
33
|
+
</div>
|
|
34
|
+
<div className="managed-app-actions">
|
|
35
|
+
{app.actions.map((action) => <AppActionButton action={action.id} app={app} disabled={!action.enabled} key={action.id} onJob={onJob} commandPreview={action.command_preview} />)}
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
<div className="managed-app-details">
|
|
39
|
+
<ToggleSwitch checked={app.aidevops_install} disabled={lockedPolicy} label="setup installs" onChange={(value) => onPolicyToggle("setup", value)} />
|
|
40
|
+
<ToggleSwitch checked={app.aidevops_update} disabled={lockedPolicy} label="update maintains" onChange={(value) => onPolicyToggle("update", value)} />
|
|
41
|
+
{visibleJob ? <AppLogLink job={visibleJob} /> : null}
|
|
42
|
+
</div>
|
|
43
|
+
<AppMeta className="managed-app-path" label={text.path} value={app.install_path_ref} />
|
|
44
|
+
{visibleJob ? <AppActionTerminal job={visibleJob} onDismiss={onDismissJob} /> : null}
|
|
45
|
+
{visiblePolicyJobs.map((policyJob) => <AppActionTerminal job={policyJob} key={policyJob.id} onDismiss={onDismissJob} />)}
|
|
46
|
+
{visibleJob === null && visiblePolicyJobs.length === 0 ? <p className="empty-state compact-notice">No recent command output for this app. Run an action or change a policy toggle to open this app's terminal log.</p> : null}
|
|
47
|
+
</div> : null}
|
|
48
|
+
</article>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isEssentialManagedApp(app: GuiManagedAppSummary): boolean {
|
|
53
|
+
return ["core", "safety", "automation"].includes(app.category);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function SummaryChip({ label, value }: { label: string; value: string }): ReactElement {
|
|
57
|
+
const tooltip = `${label}: ${value}`;
|
|
58
|
+
return <span className="managed-summary-chip" data-tooltip={tooltip} title={tooltip}><small>{label}</small><span>{value}</span></span>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function OriginLink({ href, label }: { href: string; label: string }): ReactElement {
|
|
62
|
+
if (href.length === 0) {
|
|
63
|
+
return <span className="origin-missing">{label}: source pending</span>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return <a data-tooltip={href} href={href} onClick={(event) => openExternalLink(event, href)} rel="noreferrer" target="_blank" title={href}>{label} <FiExternalLink aria-hidden="true" /></a>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function ToggleSwitch({ checked, disabled = false, label, onChange }: { checked: boolean; disabled?: boolean; label: string; onChange: (checked: boolean) => void }): ReactElement {
|
|
70
|
+
return <button aria-pressed={checked} className={checked ? "managed-toggle checked" : "managed-toggle"} data-tooltip={disabled ? "Essential aidevops component; policy is locked on" : undefined} disabled={disabled} onClick={() => onChange(!checked)} title={disabled ? "Essential aidevops component; policy is locked on" : undefined} type="button"><span className="managed-toggle-label">{label}</span><span aria-hidden="true" className={checked ? "switch-track checked" : "switch-track"}><span /></span></button>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function AppMeta({ className = "", label, value }: { className?: string; label: string; value: string }): ReactElement {
|
|
74
|
+
return <span className={className.length > 0 ? `app-meta ${className}` : "app-meta"}><small>{label}</small><strong>{value}</strong></span>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function AppLogLink({ job }: { job: GuiAppActionJobSummary }): ReactElement {
|
|
78
|
+
return <a className="app-meta app-log-link" href={`#app-job-${job.id}`}><small>Logs</small><strong>{job.action} · {job.status}</strong></a>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function AppActionButton({ action, app, commandPreview, disabled, onJob }: { action: GuiAppActionId; app: GuiManagedAppSummary; commandPreview: string; disabled: boolean; onJob: (job: GuiAppActionJobSummary) => void }): ReactElement {
|
|
82
|
+
const icon = action === "install" ? <FiDownload /> : action === "update" ? <FiRefreshCw /> : action === "reinstall" ? <FiRepeat /> : <FiTrash2 />;
|
|
83
|
+
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
84
|
+
|
|
85
|
+
async function runAction(): Promise<void> {
|
|
86
|
+
if (disabled) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const response = await fetch(`/api/apps/${encodeURIComponent(app.id)}/actions/${action}`, { method: "POST" });
|
|
91
|
+
const envelope = await response.json() as GuiResponseEnvelope<GuiAppActionJobSummary>;
|
|
92
|
+
onJob(envelope.data);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return <>
|
|
96
|
+
<button aria-label={`${action} ${app.name}`} className={action === "remove" ? "app-action-button remove" : "app-action-button"} data-tooltip={commandPreview} disabled={disabled} onClick={() => setConfirmOpen(true)} type="button">{icon}<span>{action}</span></button>
|
|
97
|
+
{confirmOpen ? <ConfirmActionModal action={action} app={app} commandPreview={commandPreview} close={() => setConfirmOpen(false)} confirm={() => { setConfirmOpen(false); void runAction(); }} /> : null}
|
|
98
|
+
</>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function ConfirmActionModal({ action, app, close, commandPreview, confirm }: { action: GuiAppActionId; app: GuiManagedAppSummary; close: () => void; commandPreview: string; confirm: () => void }): ReactElement {
|
|
102
|
+
return (
|
|
103
|
+
<div className="modal-backdrop" role="presentation">
|
|
104
|
+
<section aria-modal="true" className="confirm-modal" role="dialog" aria-labelledby={`confirm-${app.id}-${action}`}>
|
|
105
|
+
<p className="eyebrow">Confirm action</p>
|
|
106
|
+
<h3 id={`confirm-${app.id}-${action}`}>{action} {app.name}?</h3>
|
|
107
|
+
<p>This will start the allowlisted background command for this app.</p>
|
|
108
|
+
<code>{commandPreview}</code>
|
|
109
|
+
<div className="confirm-modal-actions">
|
|
110
|
+
<button className="secondary-action" onClick={close} type="button">Cancel</button>
|
|
111
|
+
<button className={action === "remove" ? "app-action-button remove" : "app-action-button"} onClick={confirm} type="button">Confirm</button>
|
|
112
|
+
</div>
|
|
113
|
+
</section>
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function openExternalLink(event: ReactMouseEvent<HTMLAnchorElement>, href: string): void {
|
|
119
|
+
event.stopPropagation();
|
|
120
|
+
if (!shouldUseBrowserDefault(event)) {
|
|
121
|
+
event.preventDefault();
|
|
122
|
+
openExternalDestination(href);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function shouldUseBrowserDefault(event: ReactMouseEvent<HTMLAnchorElement>): boolean {
|
|
127
|
+
return [event.defaultPrevented, event.metaKey, event.ctrlKey, event.shiftKey, event.altKey].some(Boolean);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function openExternalDestination(href: string): void {
|
|
131
|
+
if (document.documentElement.dataset.desktopShell === "macos") {
|
|
132
|
+
window.location.assign(href);
|
|
133
|
+
} else {
|
|
134
|
+
const opened = window.open(href, "_blank", "noopener,noreferrer");
|
|
135
|
+
if (opened === null) {
|
|
136
|
+
window.location.assign(href);
|
|
137
|
+
} else {
|
|
138
|
+
opened.opener = null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import type { MouseEvent as ReactMouseEvent, ReactElement } from "react";
|
|
2
|
+
import type { IconType } from "react-icons";
|
|
3
|
+
import { FaApple, FaLinux, FaWindows } from "react-icons/fa";
|
|
4
|
+
import { FiCode, FiExternalLink, FiGlobe, FiMonitor, FiTerminal } from "react-icons/fi";
|
|
5
|
+
import { IoLogoAndroid } from "react-icons/io";
|
|
6
|
+
import { SiIos } from "react-icons/si";
|
|
7
|
+
import { text } from "./app-model";
|
|
8
|
+
|
|
9
|
+
export type RecommendedOsId = "all" | "macos" | "linux" | "windows" | "ios" | "android";
|
|
10
|
+
export type RecommendedPlatformId = "webapp" | "saas" | "cli" | "api";
|
|
11
|
+
export type RecommendedPlatformFilterId = "all" | RecommendedPlatformId;
|
|
12
|
+
|
|
13
|
+
interface TabOption<T extends string> {
|
|
14
|
+
id: T;
|
|
15
|
+
label: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface RecommendedApp {
|
|
19
|
+
name: string;
|
|
20
|
+
description: string;
|
|
21
|
+
websiteUrl: string;
|
|
22
|
+
alternativeToUrl?: string;
|
|
23
|
+
repoUrl?: string;
|
|
24
|
+
iosUrl?: string;
|
|
25
|
+
androidUrl?: string;
|
|
26
|
+
fdroidUrl?: string;
|
|
27
|
+
obtainiumUrl?: string;
|
|
28
|
+
os: RecommendedOsId[];
|
|
29
|
+
platforms: RecommendedPlatformId[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const recommendedPlatformTabs: TabOption<RecommendedPlatformFilterId>[] = [
|
|
33
|
+
{ id: "all", label: "All" },
|
|
34
|
+
{ id: "webapp", label: "WebApp" },
|
|
35
|
+
{ id: "saas", label: "SaaS" },
|
|
36
|
+
{ id: "cli", label: "CLI" },
|
|
37
|
+
{ id: "api", label: "API" },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const recommendedOsTabs: TabOption<RecommendedOsId>[] = [
|
|
41
|
+
{ id: "all", label: "All OS" },
|
|
42
|
+
{ id: "macos", label: "macOS" },
|
|
43
|
+
{ id: "linux", label: "Linux" },
|
|
44
|
+
{ id: "windows", label: "Windows" },
|
|
45
|
+
{ id: "ios", label: "iOS" },
|
|
46
|
+
{ id: "android", label: "Android" },
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
export const recommendedApps = ([
|
|
50
|
+
{ name: "Affinity Studio", description: "Creative design suite.", websiteUrl: "https://www.affinity.studio/", alternativeToUrl: "https://alternativeto.net/software/affinity-1/", os: ["macos", "windows"], platforms: [] },
|
|
51
|
+
{ name: "Bitwarden", description: "Open source password manager.", websiteUrl: "https://bitwarden.com", alternativeToUrl: "https://alternativeto.net/software/bitwarden--free-password-manager/", repoUrl: "https://github.com/bitwarden", iosUrl: "https://itunes.apple.com/app/bitwarden-free-password-manager/id1137397744?mt=8", androidUrl: "https://play.google.com/store/apps/details?id=com.x8bit.bitwarden", fdroidUrl: "https://mobileapp.bitwarden.com/fdroid/repo", os: ["macos", "linux", "windows", "ios", "android"], platforms: ["webapp", "saas", "cli", "api"] },
|
|
52
|
+
{ name: "Brave Browser", description: "Privacy-focused web browser.", websiteUrl: "https://brave.com/", alternativeToUrl: "https://alternativeto.net/software/brave/", iosUrl: "https://apps.apple.com/app/brave-private-web-browser-vpn/id1052879175?mt=8", androidUrl: "https://play.google.com/store/apps/details?id=com.brave.browser", os: ["macos", "linux", "windows", "ios", "android"], platforms: [] },
|
|
53
|
+
{ name: "Cloudron", description: "Self-hosted app platform.", websiteUrl: "https://www.cloudron.io/", alternativeToUrl: "https://alternativeto.net/software/cloudron/", os: ["linux"], platforms: ["webapp", "cli", "api"] },
|
|
54
|
+
{ name: "Collabora Online", description: "Online office collaboration.", websiteUrl: "https://www.collaboraonline.com/collabora-online/", alternativeToUrl: "https://alternativeto.net/software/collabora-online/", os: ["linux"], platforms: ["webapp", "api"] },
|
|
55
|
+
{ name: "Cometly", description: "Marketing attribution analytics.", websiteUrl: "https://www.cometly.com/", os: [], platforms: ["saas", "api"] },
|
|
56
|
+
{ name: "DaVinci Resolve", description: "Professional video editing, color, VFX, and audio post-production.", websiteUrl: "https://www.blackmagicdesign.com/products/davinciresolve/", alternativeToUrl: "https://alternativeto.net/software/davinci-resolve/", os: ["macos", "linux", "windows"], platforms: [] },
|
|
57
|
+
{ name: "DocuSeal", description: "Open source document signing.", websiteUrl: "https://www.docuseal.com/", alternativeToUrl: "https://alternativeto.net/software/docuseal/", os: ["linux"], platforms: ["webapp", "saas", "api"] },
|
|
58
|
+
{ name: "Element", description: "Matrix messaging client.", websiteUrl: "https://element.io/", alternativeToUrl: "https://alternativeto.net/software/element-app/", iosUrl: "https://apps.apple.com/app/id1631335820", androidUrl: "https://play.google.com/store/apps/details?id=io.element.android.x", fdroidUrl: "https://f-droid.org/en/packages/io.element.android.x/", os: ["macos", "linux", "windows", "ios", "android"], platforms: ["webapp"] },
|
|
59
|
+
{ name: "Enpass", description: "Offline-first password manager.", websiteUrl: "https://www.enpass.io/", alternativeToUrl: "https://alternativeto.net/software/enpass/", iosUrl: "https://apps.apple.com/app/apple-store/id455566716?pt=637991", androidUrl: "https://play.google.com/store/apps/details?id=io.enpass.app", os: ["macos", "linux", "windows", "ios", "android"], platforms: [] },
|
|
60
|
+
{ name: "EspoCRM", description: "Open source CRM.", websiteUrl: "https://www.espocrm.com/", alternativeToUrl: "https://alternativeto.net/software/espocrm/", os: ["linux"], platforms: ["webapp", "saas", "api"] },
|
|
61
|
+
{ name: "Fathom Analytics", description: "Privacy-first analytics.", websiteUrl: "https://usefathom.com/", alternativeToUrl: "https://alternativeto.net/software/fathom-analytics/", os: [], platforms: ["saas", "api"] },
|
|
62
|
+
{ name: "FontBase", description: "Font management and reference tool.", websiteUrl: "https://fontba.se/", alternativeToUrl: "https://alternativeto.net/software/fontbase/", os: ["macos"], platforms: [] },
|
|
63
|
+
{ name: "Forgejo", description: "Self-hosted Git forge.", websiteUrl: "https://forgejo.org/", alternativeToUrl: "https://alternativeto.net/software/forgejo/", os: ["linux"], platforms: ["webapp", "cli", "api"] },
|
|
64
|
+
{ name: "Ghost", description: "Publishing platform.", websiteUrl: "https://ghost.org/", alternativeToUrl: "https://alternativeto.net/software/ghost/", os: ["linux"], platforms: ["webapp", "saas", "cli", "api"] },
|
|
65
|
+
{ name: "Gitea", description: "Self-hosted Git service.", websiteUrl: "https://about.gitea.com/", alternativeToUrl: "https://alternativeto.net/software/gitea/", os: ["linux", "windows"], platforms: ["webapp", "cli", "api"] },
|
|
66
|
+
{ name: "GitLab", description: "DevSecOps and Git hosting platform.", websiteUrl: "https://gitlab.com/", alternativeToUrl: "https://alternativeto.net/software/gitlab/", os: ["linux"], platforms: ["webapp", "saas", "cli", "api"] },
|
|
67
|
+
{ name: "LibreOffice", description: "Open source office suite.", websiteUrl: "https://www.libreoffice.org/", alternativeToUrl: "https://alternativeto.net/software/libreoffice/", os: ["macos", "linux", "windows"], platforms: [] },
|
|
68
|
+
{ name: "LocalWP", description: "Local WordPress development.", websiteUrl: "https://localwp.com/", alternativeToUrl: "https://alternativeto.net/software/local-by-flywheel/", os: ["macos", "linux", "windows"], platforms: [] },
|
|
69
|
+
{ name: "Matomo", description: "Open analytics platform.", websiteUrl: "https://matomo.org/", alternativeToUrl: "https://alternativeto.net/software/piwik/", os: ["linux"], platforms: ["webapp", "saas", "api"] },
|
|
70
|
+
{ name: "Nextcloud", description: "Open source content collaboration platform.", websiteUrl: "https://nextcloud.com/", alternativeToUrl: "https://alternativeto.net/software/nextcloud/", repoUrl: "https://github.com/nextcloud", iosUrl: "https://itunes.apple.com/us/app/nextcloud/id1125420102?mt=8", androidUrl: "https://play.google.com/store/apps/details?id=com.nextcloud.client", fdroidUrl: "https://f-droid.org/packages/com.nextcloud.client/", os: ["macos", "linux", "windows", "ios", "android"], platforms: ["webapp", "saas", "api"] },
|
|
71
|
+
{ name: "Nextcloud Talk", description: "Open source video calls, chat, and collaboration for Nextcloud.", websiteUrl: "https://nextcloud.com/talk/", repoUrl: "https://github.com/nextcloud/spreed", iosUrl: "https://apps.apple.com/us/app/nextcloud-talk/id1296825574", androidUrl: "https://play.google.com/store/apps/details?id=com.nextcloud.talk2&hl=en", os: ["ios", "android"], platforms: ["webapp"] },
|
|
72
|
+
{ name: "OBS Studio", description: "Open source video recording and live streaming.", websiteUrl: "https://obsproject.com/", alternativeToUrl: "https://alternativeto.net/software/open-broadcaster-software/", repoUrl: "https://github.com/obsproject/obs-studio", os: ["macos", "linux", "windows"], platforms: [] },
|
|
73
|
+
{ name: "ONLYOFFICE", description: "Office and document collaboration.", websiteUrl: "https://www.onlyoffice.com/", alternativeToUrl: "https://alternativeto.net/software/onlyoffice/", repoUrl: "https://github.com/ONLYOFFICE/", iosUrl: "https://apps.apple.com/us/app/onlyoffice-documents/id944896972", androidUrl: "https://play.google.com/store/apps/details?id=com.onlyoffice.documents", os: ["macos", "linux", "windows", "ios", "android"], platforms: ["webapp", "saas", "api"] },
|
|
74
|
+
{ name: "OpenScreen", description: "Open screen-sharing project.", websiteUrl: "https://github.com/getopenscreen/openscreen/releases", alternativeToUrl: "https://alternativeto.net/software/openscreen/", repoUrl: "https://github.com/getopenscreen/openscreen", os: ["macos", "linux", "windows"], platforms: [] },
|
|
75
|
+
{ name: "Osaurus", description: "AI app workspace.", websiteUrl: "https://osaurus.ai/", alternativeToUrl: "https://alternativeto.net/software/osaurus/", os: ["macos"], platforms: [] },
|
|
76
|
+
{ name: "Parallels Desktop", description: "Run Windows, Linux, and virtual machines on macOS.", websiteUrl: "https://www.parallels.com/products/desktop/", alternativeToUrl: "https://alternativeto.net/software/parallels-desktop/about/", os: ["macos"], platforms: [] },
|
|
77
|
+
{ name: "PDF Studio", description: "PDF editor.", websiteUrl: "https://www.qoppa.com/pdfstudio/", alternativeToUrl: "https://alternativeto.net/software/qoppa-pdf-studio/", os: ["macos", "linux", "windows"], platforms: [] },
|
|
78
|
+
{ name: "Pixelmator Pro", description: "Professional image editor for macOS.", websiteUrl: "https://www.apple.com/pixelmator-pro/", os: ["macos"], platforms: [] },
|
|
79
|
+
{ name: "PostHog", description: "Product analytics platform.", websiteUrl: "https://posthog.com/", alternativeToUrl: "https://alternativeto.net/software/posthog/", os: ["linux"], platforms: ["webapp", "saas", "cli", "api"] },
|
|
80
|
+
{ name: "Postiz", description: "Social media scheduling.", websiteUrl: "https://postiz.com/", alternativeToUrl: "https://alternativeto.net/software/postiz/", os: ["linux"], platforms: ["webapp", "saas", "api"] },
|
|
81
|
+
{ name: "PrivateBin", description: "Zero-knowledge pastebin.", websiteUrl: "https://privatebin.info/", alternativeToUrl: "https://alternativeto.net/software/privatebin/", repoUrl: "https://github.com/PrivateBin/PrivateBin", os: ["linux"], platforms: ["webapp", "api"] },
|
|
82
|
+
{ name: "Proxmox", description: "Virtualization platform.", websiteUrl: "https://www.proxmox.com/", alternativeToUrl: "https://alternativeto.net/software/proxmox-virtual-environment/", os: ["linux"], platforms: ["webapp", "cli", "api"] },
|
|
83
|
+
{ name: "QuickFile", description: "Accounting platform.", websiteUrl: "https://www.quickfile.co.uk/", os: [], platforms: ["saas", "api"] },
|
|
84
|
+
{ name: "Reframed", description: "Creative framing utility.", websiteUrl: "https://www.reframed.dev/", alternativeToUrl: "https://alternativeto.net/software/reframed/", os: ["macos"], platforms: [] },
|
|
85
|
+
{ name: "Rybbit", description: "Web analytics.", websiteUrl: "https://rybbit.com/", alternativeToUrl: "https://alternativeto.net/software/rybbit/", os: ["linux"], platforms: ["webapp", "saas", "api"] },
|
|
86
|
+
{ name: "SchildiChat", description: "Matrix messaging client.", websiteUrl: "https://schildi.chat/", alternativeToUrl: "https://alternativeto.net/software/schildichat/", androidUrl: "https://play.google.com/store/apps/details?id=chat.schildi.android", fdroidUrl: "https://schildi.chat/next/install-from-sc-fdroid", os: ["macos", "linux", "windows", "android"], platforms: ["webapp"] },
|
|
87
|
+
{ name: "ScreenFlow", description: "Screen recording and editing.", websiteUrl: "https://www.telestream.net/screenflow/", alternativeToUrl: "https://alternativeto.net/software/screenflow/", os: ["macos"], platforms: [] },
|
|
88
|
+
{ name: "SEO Utils", description: "Desktop SEO tools.", websiteUrl: "https://seoutils.app/", os: ["macos", "linux", "windows"], platforms: [] },
|
|
89
|
+
{ name: "Shottr", description: "macOS screenshot utility.", websiteUrl: "https://shottr.cc/", alternativeToUrl: "https://alternativeto.net/software/shottr/", os: ["macos"], platforms: [] },
|
|
90
|
+
{ name: "Signal", description: "Private messenger.", websiteUrl: "https://signal.org/", alternativeToUrl: "https://alternativeto.net/software/signal-private-messenger/", iosUrl: "https://apps.apple.com/us/app/signal-private-messenger/id874139669", androidUrl: "https://play.google.com/store/apps/details?id=org.thoughtcrime.securesms", os: ["macos", "linux", "windows", "ios", "android"], platforms: [] },
|
|
91
|
+
{ name: "SimpleX Chat", description: "Private messenger with no user IDs.", websiteUrl: "https://simplex.chat/", alternativeToUrl: "https://alternativeto.net/software/simplex-chat/about/", repoUrl: "https://github.com/simplex-chat", iosUrl: "https://apps.apple.com/us/app/simplex-chat/id1605771084", androidUrl: "https://play.google.com/store/apps/details?id=chat.simplex.app", fdroidUrl: "https://simplex.chat/fdroid", os: ["macos", "linux", "windows", "ios", "android"], platforms: ["cli"] },
|
|
92
|
+
{ name: "Telegram", description: "Cloud-based mobile and desktop messaging app.", websiteUrl: "https://telegram.org/", iosUrl: "https://apps.apple.com/us/app/telegram-messenger/id686449807", androidUrl: "https://play.google.com/store/apps/details?id=org.telegram.messenger", os: ["macos", "linux", "windows", "ios", "android"], platforms: ["webapp", "api"] },
|
|
93
|
+
{ name: "Thunderbird", description: "Email and calendar app.", websiteUrl: "https://www.thunderbird.net/", alternativeToUrl: "https://alternativeto.net/software/mozilla-thunderbird/about/", os: ["macos", "linux", "windows"], platforms: [] },
|
|
94
|
+
{ name: "Ubicloud", description: "Open cloud platform.", websiteUrl: "https://www.ubicloud.com/", alternativeToUrl: "https://alternativeto.net/software/ubicloud/about/", repoUrl: "https://github.com/ubicloud/ubicloud", os: ["linux"], platforms: ["webapp", "saas", "cli", "api"] },
|
|
95
|
+
{ name: "Vaultwarden", description: "Alternative Bitwarden server implementation.", websiteUrl: "https://github.com/dani-garcia/vaultwarden", alternativeToUrl: "https://alternativeto.net/software/vaultwarden/", repoUrl: "https://github.com/dani-garcia/vaultwarden", os: ["linux"], platforms: ["webapp", "api"] },
|
|
96
|
+
{ name: "VideoProc", description: "Video processing toolkit.", websiteUrl: "https://www.videoproc.com/", alternativeToUrl: "https://alternativeto.net/software/videoproc/about/", os: ["macos", "windows"], platforms: [] },
|
|
97
|
+
{ name: "VirtualBox", description: "Virtualization app.", websiteUrl: "https://www.virtualbox.org/", alternativeToUrl: "https://alternativeto.net/software/virtualbox/about/", os: ["macos", "linux", "windows"], platforms: ["cli"] },
|
|
98
|
+
{ name: "WordPress", description: "Open source publishing platform.", websiteUrl: "https://wordpress.org/", alternativeToUrl: "https://alternativeto.net/software/wordpress/about/", os: ["linux"], platforms: ["webapp", "saas", "cli", "api"] },
|
|
99
|
+
] satisfies RecommendedApp[]).sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: "base" }));
|
|
100
|
+
|
|
101
|
+
function TabNav<T extends string>({ label, onChange, tabs, value }: { label: string; onChange: (value: T) => void; tabs: TabOption<T>[]; value: T }): ReactElement {
|
|
102
|
+
return <div aria-label={label} className="pill-tabs app-subnav" role="tablist">{tabs.map((tab) => <button aria-selected={value === tab.id} className={value === tab.id ? "active" : ""} key={tab.id} onClick={() => onChange(tab.id)} role="tab" type="button">{tab.label}</button>)}</div>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function RecommendedAppsSurface({ apps, os, platform, setOs, setPlatform }: { apps: RecommendedApp[]; os: RecommendedOsId; platform: RecommendedPlatformFilterId; setOs: (os: RecommendedOsId) => void; setPlatform: (platform: RecommendedPlatformFilterId) => void }): ReactElement {
|
|
106
|
+
return <>
|
|
107
|
+
<div className="recommended-filter-tabs">
|
|
108
|
+
<TabNav label="Recommended app operating system filters" tabs={recommendedOsTabs} value={os} onChange={setOs} />
|
|
109
|
+
<TabNav label="Recommended app platform filters" tabs={recommendedPlatformTabs} value={platform} onChange={setPlatform} />
|
|
110
|
+
</div>
|
|
111
|
+
<div className="recommended-app-grid">
|
|
112
|
+
{apps.map((app) => <RecommendedAppCard app={app} key={app.name} setOs={setOs} setPlatform={setPlatform} />)}
|
|
113
|
+
</div>
|
|
114
|
+
</>;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function RecommendedAppCard({ app, setOs, setPlatform }: { app: RecommendedApp; setOs: (os: RecommendedOsId) => void; setPlatform: (platform: RecommendedPlatformFilterId) => void }): ReactElement {
|
|
118
|
+
return <article className="recommended-app-card">
|
|
119
|
+
<div>
|
|
120
|
+
<strong>{app.name}</strong>
|
|
121
|
+
<p>{app.description}</p>
|
|
122
|
+
</div>
|
|
123
|
+
<div className="app-icon-groups">
|
|
124
|
+
<PlatformIconList platforms={app.platforms} setPlatform={setPlatform} />
|
|
125
|
+
<OsIconList os={app.os} setOs={setOs} />
|
|
126
|
+
</div>
|
|
127
|
+
<div className="managed-app-links plain-links">
|
|
128
|
+
<OriginLink href={app.websiteUrl} label={text.website} />
|
|
129
|
+
{app.alternativeToUrl ? <OriginLink href={app.alternativeToUrl} label="AlternativeTo" /> : null}
|
|
130
|
+
{app.repoUrl ? <OriginLink href={app.repoUrl} label="Repo" /> : null}
|
|
131
|
+
{app.iosUrl ? <OriginLink href={app.iosUrl} label="iOS" /> : null}
|
|
132
|
+
{app.androidUrl ? <OriginLink href={app.androidUrl} label="Android" /> : null}
|
|
133
|
+
{app.fdroidUrl ? <OriginLink href={app.fdroidUrl} label="F-Droid" /> : null}
|
|
134
|
+
{app.obtainiumUrl ? <OriginLink href={app.obtainiumUrl} label="Obtainium" /> : null}
|
|
135
|
+
</div>
|
|
136
|
+
</article>;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function OriginLink({ href, label }: { href: string; label: string }): ReactElement {
|
|
140
|
+
if (href.length === 0) {
|
|
141
|
+
return <span className="origin-missing">{label}: source pending</span>;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return <a data-tooltip={href} href={href} onClick={(event) => openExternalLink(event, href)} rel="noreferrer" target="_blank" title={href}>{label} <FiExternalLink aria-hidden="true" /></a>;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function PlatformIconList({ platforms, setPlatform }: { platforms: RecommendedPlatformId[]; setPlatform: (platform: RecommendedPlatformFilterId) => void }): ReactElement | null {
|
|
148
|
+
if (platforms.length === 0) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return <span className="os-icon-list platform-icon-list">{platforms.map((platform) => <PlatformIcon id={platform} key={platform} setPlatform={setPlatform} />)}</span>;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function PlatformIcon({ id, setPlatform }: { id: RecommendedPlatformId; setPlatform: (platform: RecommendedPlatformFilterId) => void }): ReactElement {
|
|
156
|
+
const iconMap: Record<RecommendedPlatformId, { Icon: IconType; label: string }> = {
|
|
157
|
+
webapp: { Icon: FiMonitor, label: "WebApp" },
|
|
158
|
+
saas: { Icon: FiGlobe, label: "SaaS" },
|
|
159
|
+
cli: { Icon: FiTerminal, label: "CLI" },
|
|
160
|
+
api: { Icon: FiCode, label: "API" },
|
|
161
|
+
};
|
|
162
|
+
const { Icon, label } = iconMap[id];
|
|
163
|
+
|
|
164
|
+
return <button aria-label={`Filter by ${label}`} data-tooltip={`Filter by ${label}`} onClick={() => setPlatform(id)} title={`Filter by ${label}`} type="button"><Icon aria-hidden="true" focusable="false" /></button>;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function OsIconList({ os, setOs }: { os: RecommendedOsId[]; setOs: (os: RecommendedOsId) => void }): ReactElement | null {
|
|
168
|
+
if (os.length === 0) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return <span className="os-icon-list">{os.map((item) => <OsIcon id={item} key={item} setOs={setOs} />)}</span>;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function OsIcon({ id, setOs }: { id: RecommendedOsId; setOs: (os: RecommendedOsId) => void }): ReactElement {
|
|
176
|
+
const iconMap: Record<RecommendedOsId, { Icon: IconType; label: string }> = {
|
|
177
|
+
all: { Icon: FiGlobe, label: "Web app" },
|
|
178
|
+
macos: { Icon: FaApple, label: "macOS" },
|
|
179
|
+
linux: { Icon: FaLinux, label: "Linux" },
|
|
180
|
+
windows: { Icon: FaWindows, label: "Windows" },
|
|
181
|
+
ios: { Icon: SiIos, label: "iOS" },
|
|
182
|
+
android: { Icon: IoLogoAndroid, label: "Android" },
|
|
183
|
+
};
|
|
184
|
+
const { Icon, label } = iconMap[id];
|
|
185
|
+
|
|
186
|
+
return <button aria-label={`Filter by ${label}`} data-tooltip={`Filter by ${label}`} onClick={() => setOs(id)} title={`Filter by ${label}`} type="button"><Icon aria-hidden="true" focusable="false" /></button>;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function recommendedAppMatchesFilters(app: RecommendedApp, platform: RecommendedPlatformFilterId, os: RecommendedOsId): boolean {
|
|
190
|
+
const platformMatches = platform === "all" || app.platforms.includes(platform);
|
|
191
|
+
const osMatches = os === "all" || app.os.includes(os);
|
|
192
|
+
|
|
193
|
+
return platformMatches && osMatches;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function nextRecommendedFilterValue<T extends string>(current: T, next: T, defaultValue: T): T {
|
|
197
|
+
return current === next && next !== defaultValue ? defaultValue : next;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function openExternalLink(event: ReactMouseEvent<HTMLAnchorElement>, href: string): void {
|
|
201
|
+
event.stopPropagation();
|
|
202
|
+
if (!shouldUseBrowserDefault(event)) {
|
|
203
|
+
event.preventDefault();
|
|
204
|
+
openExternalDestination(href);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function shouldUseBrowserDefault(event: ReactMouseEvent<HTMLAnchorElement>): boolean {
|
|
209
|
+
return [event.defaultPrevented, event.metaKey, event.ctrlKey, event.shiftKey, event.altKey].some(Boolean);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function openExternalDestination(href: string): void {
|
|
213
|
+
if (document.documentElement.dataset.desktopShell === "macos") {
|
|
214
|
+
window.location.assign(href);
|
|
215
|
+
} else {
|
|
216
|
+
const opened = window.open(href, "_blank", "noopener,noreferrer");
|
|
217
|
+
if (opened === null) {
|
|
218
|
+
window.location.assign(href);
|
|
219
|
+
} else {
|
|
220
|
+
opened.opener = null;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
package/setup.sh
CHANGED
|
@@ -12,7 +12,7 @@ shopt -s inherit_errexit 2>/dev/null || true
|
|
|
12
12
|
# AI Assistant Server Access Framework Setup Script
|
|
13
13
|
# Helps developers set up the framework for their infrastructure
|
|
14
14
|
#
|
|
15
|
-
# Version: 3.29.
|
|
15
|
+
# Version: 3.29.22
|
|
16
16
|
#
|
|
17
17
|
# Quick Install:
|
|
18
18
|
# npm install -g aidevops && aidevops update (recommended)
|