aidevops 3.29.21 → 3.29.23

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.
@@ -1,19 +1,11 @@
1
- import type { GuiAppActionId, GuiAppActionJobSummary, GuiManagedAppSummary, GuiResponseEnvelope, GuiStatusData } from "@aidevops/gui-shared";
2
- import { type CSSProperties, type Dispatch, type FocusEvent as ReactFocusEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactElement, type ReactNode, type SetStateAction, useEffect, useRef, useState } from "react";
3
- import type { IconType } from "react-icons";
4
- import { FaApple, FaLinux, FaWindows } from "react-icons/fa";
5
- import { FiChevronDown, FiCode, FiDownload, FiExternalLink, FiGlobe, FiMonitor, FiRefreshCw, FiRepeat, FiTerminal, FiTrash2, FiX } from "react-icons/fi";
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
- let draftRowCounter = 0;
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
- if (["core", "safety", "automation"].includes(app.category)) {
324
- return "core";
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 isEssentialManagedApp(app: GuiManagedAppSummary): boolean {
343
- return ["core", "safety", "automation"].includes(app.category);
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;
@@ -599,6 +238,7 @@ function writeManagedPolicyToggles(policy: ManagedPolicyToggleState): void {
599
238
  }
600
239
 
601
240
  interface AppTooltipState {
241
+ align: "start" | "center" | "end";
602
242
  text: string;
603
243
  x: number;
604
244
  y: number;
@@ -631,7 +271,16 @@ function tooltipForElement(element: HTMLElement): AppTooltipState | null {
631
271
  }
632
272
 
633
273
  const rect = element.getBoundingClientRect();
634
- return { text: textValue, x: rect.left + (rect.width / 2), y: rect.top };
274
+ const viewportWidth = document.documentElement.clientWidth || window.innerWidth;
275
+ const edgeSafeWidth = 240;
276
+ if (rect.left < edgeSafeWidth) {
277
+ return { align: "start", text: textValue, x: rect.left, y: rect.top };
278
+ }
279
+ if (viewportWidth - rect.right < edgeSafeWidth) {
280
+ return { align: "end", text: textValue, x: rect.right, y: rect.top };
281
+ }
282
+
283
+ return { align: "center", text: textValue, x: rect.left + (rect.width / 2), y: rect.top };
635
284
  }
636
285
 
637
286
  function AppTooltip({ tooltip }: { tooltip: AppTooltipState }): ReactElement {
@@ -640,115 +289,5 @@ function AppTooltip({ tooltip }: { tooltip: AppTooltipState }): ReactElement {
640
289
  "--tooltip-y": `${tooltip.y}px`,
641
290
  } as CSSProperties;
642
291
 
643
- return <div className="app-global-tooltip" role="tooltip" style={style}>{tooltip.text}</div>;
644
- }
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;
292
+ return <div className={`app-global-tooltip ${tooltip.align}`} role="tooltip" style={style}>{tooltip.text}</div>;
754
293
  }