mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-02 10:34:55 +00:00
basic functionality
This commit is contained in:
5
.cursor/rules/Migrations.mdc
Normal file
5
.cursor/rules/Migrations.mdc
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
Don't write or edit migrations in `server/setup` unless specificall instructed to do so.
|
||||
@@ -2077,6 +2077,7 @@
|
||||
"subnetPlaceholder": "Subnet",
|
||||
"addressDescription": "The internal address of the client. Must fall within the organization's subnet.",
|
||||
"selectSites": "Select sites",
|
||||
"selectLabels": "Select labels",
|
||||
"sitesDescription": "The client will have connectivity to the selected sites",
|
||||
"clientInstallOlm": "Install Machine Client",
|
||||
"clientInstallOlmDescription": "Install the machine client for your system",
|
||||
@@ -2304,6 +2305,7 @@
|
||||
"createInternalResourceDialogSite": "Site",
|
||||
"selectSite": "Select site...",
|
||||
"multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# sites}}",
|
||||
"labelsSelectorLabelsCount": "{count, plural, one {# label} other {# labels}}",
|
||||
"noSitesFound": "No sites found.",
|
||||
"createInternalResourceDialogProtocol": "Protocol",
|
||||
"createInternalResourceDialogTcp": "TCP",
|
||||
@@ -3542,6 +3544,47 @@
|
||||
"memberPortalEmailWhitelist": "Email Whitelist",
|
||||
"memberPortalResourceDisabled": "Resource Disabled",
|
||||
"memberPortalShowingResources": "Showing {start}-{end} of {total} resources",
|
||||
"resourceLauncherTitle": "Resource Launcher",
|
||||
"resourceLauncherDescription": "View resource details and launch them from one place",
|
||||
"resourceLauncherSearchPlaceholder": "Search all sites...",
|
||||
"resourceLauncherDefaultView": "Default",
|
||||
"resourceLauncherSaveView": "Save View",
|
||||
"resourceLauncherSaveToCurrentView": "Save to Current View",
|
||||
"resourceLauncherSaveAsNewView": "Save as New View",
|
||||
"resourceLauncherSaveAsNewViewDescription": "Give this view a name to save your current filters and layout.",
|
||||
"resourceLauncherSaveForEveryone": "Save for Everyone",
|
||||
"resourceLauncherSaveForEveryoneDescription": "Share this view with all organization members. When unchecked, the view is only visible to you.",
|
||||
"resourceLauncherMakePersonal": "Make Personal",
|
||||
"resourceLauncherFilter": "Filter",
|
||||
"resourceLauncherSort": "Sort",
|
||||
"resourceLauncherSortAscending": "Sort ascending",
|
||||
"resourceLauncherSortDescending": "Sort descending",
|
||||
"resourceLauncherSettings": "Settings",
|
||||
"resourceLauncherGroupBy": "Group By",
|
||||
"resourceLauncherGroupBySite": "Site",
|
||||
"resourceLauncherGroupByLabel": "Label",
|
||||
"resourceLauncherLayout": "Layout",
|
||||
"resourceLauncherLayoutGrid": "Grid",
|
||||
"resourceLauncherLayoutList": "List",
|
||||
"resourceLauncherShowLabels": "Show Label Tags",
|
||||
"resourceLauncherShowSiteTags": "Show Site Tags",
|
||||
"resourceLauncherShowRecents": "Show Recents",
|
||||
"resourceLauncherDeleteView": "Delete View",
|
||||
"resourceLauncherViewAsAdmin": "View as Admin",
|
||||
"resourceLauncherUnlabeled": "Unlabeled",
|
||||
"resourceLauncherNoResourcesInGroup": "No resources in this group",
|
||||
"resourceLauncherCopiedToClipboard": "Copied to clipboard",
|
||||
"resourceLauncherCopiedAccessDescription": "Resource access has been copied to your clipboard.",
|
||||
"resourceLauncherViewNamePlaceholder": "View name",
|
||||
"resourceLauncherViewNameLabel": "View Name",
|
||||
"resourceLauncherViewSaved": "View saved",
|
||||
"resourceLauncherViewSavedDescription": "Your launcher view has been saved.",
|
||||
"resourceLauncherViewSaveFailed": "Failed to save view",
|
||||
"resourceLauncherViewSaveFailedDescription": "Could not save the launcher view. Please try again.",
|
||||
"resourceLauncherViewDeleted": "View deleted",
|
||||
"resourceLauncherViewDeletedDescription": "The launcher view has been deleted.",
|
||||
"resourceLauncherViewDeleteFailed": "Failed to delete view",
|
||||
"resourceLauncherViewDeleteFailedDescription": "Could not delete the launcher view. Please try again.",
|
||||
"memberPortalPrevious": "Previous",
|
||||
"memberPortalNext": "Next",
|
||||
"httpSettings": "HTTP Settings",
|
||||
|
||||
@@ -218,6 +218,20 @@ export const labels = pgTable("labels", {
|
||||
.notNull()
|
||||
});
|
||||
|
||||
export const launcherViews = pgTable("launcherViews", {
|
||||
viewId: serial("viewId").primaryKey(),
|
||||
orgId: varchar("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
userId: varchar("userId").references(() => users.userId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
name: varchar("name").notNull(),
|
||||
config: text("config").notNull(),
|
||||
createdAt: varchar("createdAt").notNull(),
|
||||
updatedAt: varchar("updatedAt").notNull()
|
||||
});
|
||||
|
||||
export const siteLabels = pgTable(
|
||||
"siteLabels",
|
||||
{
|
||||
@@ -1550,6 +1564,7 @@ export type RoundTripMessageTracker = InferSelectModel<
|
||||
export type Network = InferSelectModel<typeof networks>;
|
||||
export type StatusHistory = InferSelectModel<typeof statusHistory>;
|
||||
export type Label = InferSelectModel<typeof labels>;
|
||||
export type LauncherView = InferSelectModel<typeof launcherViews>;
|
||||
export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
|
||||
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
|
||||
export type UserPolicy = InferSelectModel<typeof userPolicies>;
|
||||
|
||||
@@ -221,6 +221,20 @@ export const labels = sqliteTable("labels", {
|
||||
.notNull()
|
||||
});
|
||||
|
||||
export const launcherViews = sqliteTable("launcherViews", {
|
||||
viewId: integer("viewId").primaryKey({ autoIncrement: true }),
|
||||
orgId: text("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
userId: text("userId").references(() => users.userId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
name: text("name").notNull(),
|
||||
config: text("config").notNull(),
|
||||
createdAt: text("createdAt").notNull(),
|
||||
updatedAt: text("updatedAt").notNull()
|
||||
});
|
||||
|
||||
export const siteLabels = sqliteTable(
|
||||
"siteLabels",
|
||||
{
|
||||
@@ -1549,6 +1563,7 @@ export type RoundTripMessageTracker = InferSelectModel<
|
||||
>;
|
||||
export type StatusHistory = InferSelectModel<typeof statusHistory>;
|
||||
export type Label = InferSelectModel<typeof labels>;
|
||||
export type LauncherView = InferSelectModel<typeof launcherViews>;
|
||||
export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
|
||||
export type ResourcePolicyPincode = InferSelectModel<
|
||||
typeof resourcePolicyPincode
|
||||
|
||||
@@ -17,6 +17,7 @@ import * as idp from "./idp";
|
||||
import * as blueprints from "./blueprints";
|
||||
import * as apiKeys from "./apiKeys";
|
||||
import * as logs from "./auditLogs";
|
||||
import * as launcher from "./launcher";
|
||||
import * as newt from "./newt";
|
||||
import * as olm from "./olm";
|
||||
import * as serverInfo from "./serverInfo";
|
||||
@@ -455,6 +456,42 @@ authenticated.get(
|
||||
resource.getUserResources
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/launcher/groups",
|
||||
verifyOrgAccess,
|
||||
launcher.listLauncherGroups
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/launcher/resources",
|
||||
verifyOrgAccess,
|
||||
launcher.listLauncherResources
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/launcher/views",
|
||||
verifyOrgAccess,
|
||||
launcher.listLauncherViews
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/launcher/views",
|
||||
verifyOrgAccess,
|
||||
launcher.createLauncherView
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/launcher/views/:viewId",
|
||||
verifyOrgAccess,
|
||||
launcher.updateLauncherView
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/org/:orgId/launcher/views/:viewId",
|
||||
verifyOrgAccess,
|
||||
launcher.deleteLauncherView
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/user-resource-aliases",
|
||||
verifyOrgAccess,
|
||||
|
||||
@@ -13,6 +13,7 @@ import * as apiKeys from "./apiKeys";
|
||||
import * as idp from "./idp";
|
||||
import * as logs from "./auditLogs";
|
||||
import * as siteResource from "./siteResource";
|
||||
import * as launcher from "./launcher";
|
||||
import {
|
||||
verifyApiKey,
|
||||
verifyApiKeyOrgAccess,
|
||||
@@ -159,6 +160,42 @@ authenticated.get(
|
||||
verifyApiKeyOrgAccess,
|
||||
resource.getUserResources
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/launcher/groups",
|
||||
verifyApiKeyOrgAccess,
|
||||
launcher.listLauncherGroups
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/launcher/resources",
|
||||
verifyApiKeyOrgAccess,
|
||||
launcher.listLauncherResources
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/launcher/views",
|
||||
verifyApiKeyOrgAccess,
|
||||
launcher.listLauncherViews
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/launcher/views",
|
||||
verifyApiKeyOrgAccess,
|
||||
launcher.createLauncherView
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/launcher/views/:viewId",
|
||||
verifyApiKeyOrgAccess,
|
||||
launcher.updateLauncherView
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/org/:orgId/launcher/views/:viewId",
|
||||
verifyApiKeyOrgAccess,
|
||||
launcher.deleteLauncherView
|
||||
);
|
||||
// Site Resource endpoints
|
||||
authenticated.put(
|
||||
"/org/:orgId/site-resource",
|
||||
|
||||
118
server/routers/launcher/createLauncherView.ts
Normal file
118
server/routers/launcher/createLauncherView.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { db, launcherViews } from "@server/db";
|
||||
import { response } from "@server/lib/response";
|
||||
import { getFirstString } from "@server/lib/requestParams";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import moment from "moment";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
isOrgAdminOrOwner,
|
||||
verifyLauncherOrgMembership
|
||||
} from "./launcherResourceAccess";
|
||||
import { launcherViewConfigSchema } from "./types";
|
||||
|
||||
const createLauncherViewBodySchema = z.strictObject({
|
||||
name: z.string().min(1).max(128),
|
||||
config: launcherViewConfigSchema,
|
||||
orgWide: z.boolean().optional().default(false)
|
||||
});
|
||||
|
||||
export async function createLauncherView(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const orgId = getFirstString(req.params.orgId);
|
||||
const userId = req.user?.userId;
|
||||
|
||||
if (!userId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
|
||||
);
|
||||
}
|
||||
|
||||
if (!orgId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = createLauncherViewBodySchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromZodError(parsed.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { userRoleIds } = await verifyLauncherOrgMembership(
|
||||
orgId,
|
||||
userId
|
||||
);
|
||||
|
||||
if (parsed.data.orgWide) {
|
||||
const canManageOrgWide = await isOrgAdminOrOwner(
|
||||
orgId,
|
||||
userId,
|
||||
userRoleIds
|
||||
);
|
||||
if (!canManageOrgWide) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Only administrators can create org-wide views"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const now = moment().toISOString();
|
||||
const [created] = await db
|
||||
.insert(launcherViews)
|
||||
.values({
|
||||
orgId,
|
||||
userId: parsed.data.orgWide ? null : userId,
|
||||
name: parsed.data.name,
|
||||
config: JSON.stringify(parsed.data.config),
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.returning();
|
||||
|
||||
return response(res, {
|
||||
data: {
|
||||
viewId: created.viewId,
|
||||
orgId: created.orgId,
|
||||
userId: created.userId,
|
||||
name: created.name,
|
||||
config: launcherViewConfigSchema.parse(
|
||||
JSON.parse(created.config)
|
||||
),
|
||||
createdAt: created.createdAt,
|
||||
updatedAt: created.updatedAt,
|
||||
isOrgWide: created.userId == null
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Launcher view created successfully",
|
||||
status: HttpCode.CREATED
|
||||
});
|
||||
} catch (error) {
|
||||
if (createHttpError.isHttpError(error)) {
|
||||
return next(error);
|
||||
}
|
||||
console.error("Error creating launcher view:", error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Internal server error"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
97
server/routers/launcher/deleteLauncherView.ts
Normal file
97
server/routers/launcher/deleteLauncherView.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { db, launcherViews } from "@server/db";
|
||||
import { response } from "@server/lib/response";
|
||||
import { getFirstString } from "@server/lib/requestParams";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import {
|
||||
isOrgAdminOrOwner,
|
||||
verifyLauncherOrgMembership
|
||||
} from "./launcherResourceAccess";
|
||||
|
||||
export async function deleteLauncherView(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const orgId = getFirstString(req.params.orgId);
|
||||
const viewId = Number.parseInt(
|
||||
getFirstString(req.params.viewId) ?? "",
|
||||
10
|
||||
);
|
||||
const userId = req.user?.userId;
|
||||
|
||||
if (!userId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
|
||||
);
|
||||
}
|
||||
|
||||
if (!orgId || !Number.isFinite(viewId)) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Invalid request parameters"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { userRoleIds } = await verifyLauncherOrgMembership(
|
||||
orgId,
|
||||
userId
|
||||
);
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(launcherViews)
|
||||
.where(
|
||||
and(
|
||||
eq(launcherViews.viewId, viewId),
|
||||
eq(launcherViews.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, "Launcher view not found")
|
||||
);
|
||||
}
|
||||
|
||||
const isPersonalView = existing.userId === userId;
|
||||
const isOrgWideView = existing.userId == null;
|
||||
const isAdmin = await isOrgAdminOrOwner(orgId, userId, userRoleIds);
|
||||
|
||||
if (!isPersonalView && !(isOrgWideView && isAdmin)) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"You do not have permission to delete this view"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await db.delete(launcherViews).where(eq(launcherViews.viewId, viewId));
|
||||
|
||||
return response(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Launcher view deleted successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
if (createHttpError.isHttpError(error)) {
|
||||
return next(error);
|
||||
}
|
||||
console.error("Error deleting launcher view:", error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Internal server error"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
139
server/routers/launcher/formatLauncherAccess.ts
Normal file
139
server/routers/launcher/formatLauncherAccess.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
export type SiteResourceDestinationInput = {
|
||||
mode: "host" | "cidr" | "http" | "ssh";
|
||||
destination: string | null;
|
||||
destinationPort: number | null;
|
||||
scheme: "http" | "https" | null;
|
||||
};
|
||||
|
||||
export function resolveHttpHttpsDisplayPort(
|
||||
mode: "http",
|
||||
destinationPort: number | null
|
||||
): number {
|
||||
if (destinationPort != null) {
|
||||
return destinationPort;
|
||||
}
|
||||
return 80;
|
||||
}
|
||||
|
||||
export function formatSiteResourceDestinationDisplay(
|
||||
row: SiteResourceDestinationInput
|
||||
): string {
|
||||
if (!row.destination) {
|
||||
return "";
|
||||
}
|
||||
const { mode, destination, destinationPort, scheme } = row;
|
||||
if (mode !== "http") {
|
||||
return destination;
|
||||
}
|
||||
const port = resolveHttpHttpsDisplayPort(mode, destinationPort);
|
||||
const downstreamScheme = scheme ?? "http";
|
||||
const hostPart =
|
||||
destination.includes(":") && !destination.startsWith("[")
|
||||
? `[${destination}]`
|
||||
: destination;
|
||||
return `${downstreamScheme}://${hostPart}:${port}`;
|
||||
}
|
||||
|
||||
export type PublicResourceAccessInput = {
|
||||
mode: string;
|
||||
fullDomain: string | null;
|
||||
ssl: boolean;
|
||||
proxyPort: number | null;
|
||||
wildcard: boolean;
|
||||
};
|
||||
|
||||
export type SiteResourceAccessInput = {
|
||||
mode: string;
|
||||
destination: string | null;
|
||||
destinationPort: number | null;
|
||||
scheme: "http" | "https" | null;
|
||||
ssl: boolean;
|
||||
fullDomain: string | null;
|
||||
alias: string | null;
|
||||
aliasAddress: string | null;
|
||||
};
|
||||
|
||||
export type LauncherAccessFields = {
|
||||
accessDisplay: string;
|
||||
accessCopyValue: string;
|
||||
accessUrl: string | null;
|
||||
};
|
||||
|
||||
export function formatPublicResourceAccess(
|
||||
resource: PublicResourceAccessInput
|
||||
): LauncherAccessFields {
|
||||
const browserModes = ["http", "ssh", "rdp", "vnc"];
|
||||
if (!browserModes.includes(resource.mode)) {
|
||||
const port = resource.proxyPort?.toString() ?? "";
|
||||
return {
|
||||
accessDisplay: port,
|
||||
accessCopyValue: port,
|
||||
accessUrl: null
|
||||
};
|
||||
}
|
||||
|
||||
if (!resource.fullDomain) {
|
||||
return {
|
||||
accessDisplay: "",
|
||||
accessCopyValue: "",
|
||||
accessUrl: null
|
||||
};
|
||||
}
|
||||
|
||||
const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
|
||||
return {
|
||||
accessDisplay: url,
|
||||
accessCopyValue: url,
|
||||
accessUrl: resource.wildcard ? null : url
|
||||
};
|
||||
}
|
||||
|
||||
export function formatSiteResourceAccess(
|
||||
resource: SiteResourceAccessInput
|
||||
): LauncherAccessFields {
|
||||
if (resource.alias) {
|
||||
return {
|
||||
accessDisplay: resource.alias,
|
||||
accessCopyValue: resource.alias,
|
||||
accessUrl: null
|
||||
};
|
||||
}
|
||||
|
||||
if (resource.mode === "http" && resource.fullDomain) {
|
||||
const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
|
||||
return {
|
||||
accessDisplay: url,
|
||||
accessCopyValue: url,
|
||||
accessUrl: url
|
||||
};
|
||||
}
|
||||
|
||||
const destination = formatSiteResourceDestinationDisplay({
|
||||
mode: resource.mode as SiteResourceDestinationInput["mode"],
|
||||
destination: resource.destination,
|
||||
destinationPort: resource.destinationPort,
|
||||
scheme: resource.scheme
|
||||
});
|
||||
|
||||
if (destination) {
|
||||
return {
|
||||
accessDisplay: destination,
|
||||
accessCopyValue: destination,
|
||||
accessUrl: resource.mode === "http" ? destination : null
|
||||
};
|
||||
}
|
||||
|
||||
if (resource.aliasAddress) {
|
||||
return {
|
||||
accessDisplay: resource.aliasAddress,
|
||||
accessCopyValue: resource.aliasAddress,
|
||||
accessUrl: null
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
accessDisplay: "",
|
||||
accessCopyValue: "",
|
||||
accessUrl: null
|
||||
};
|
||||
}
|
||||
7
server/routers/launcher/index.ts
Normal file
7
server/routers/launcher/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from "./types";
|
||||
export { listLauncherGroups } from "./listLauncherGroups";
|
||||
export { listLauncherResources } from "./listLauncherResources";
|
||||
export { listLauncherViews } from "./listLauncherViews";
|
||||
export { createLauncherView } from "./createLauncherView";
|
||||
export { updateLauncherView } from "./updateLauncherView";
|
||||
export { deleteLauncherView } from "./deleteLauncherView";
|
||||
1090
server/routers/launcher/launcherResourceAccess.ts
Normal file
1090
server/routers/launcher/launcherResourceAccess.ts
Normal file
File diff suppressed because it is too large
Load Diff
73
server/routers/launcher/listLauncherGroups.ts
Normal file
73
server/routers/launcher/listLauncherGroups.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { response } from "@server/lib/response";
|
||||
import { getFirstString } from "@server/lib/requestParams";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
import { listLauncherGroupsForUser } from "./launcherResourceAccess";
|
||||
import { launcherListQuerySchema } from "./types";
|
||||
|
||||
export async function listLauncherGroups(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const orgId = getFirstString(req.params.orgId);
|
||||
const userId = req.user?.userId;
|
||||
|
||||
if (!userId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
|
||||
);
|
||||
}
|
||||
|
||||
if (!orgId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = launcherListQuerySchema.safeParse(req.query);
|
||||
if (!parsed.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromZodError(parsed.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { groups, total } = await listLauncherGroupsForUser(
|
||||
orgId,
|
||||
userId,
|
||||
parsed.data
|
||||
);
|
||||
|
||||
return response(res, {
|
||||
data: {
|
||||
groups,
|
||||
pagination: {
|
||||
total,
|
||||
page: parsed.data.page,
|
||||
pageSize: parsed.data.pageSize
|
||||
}
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Launcher groups retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
if (createHttpError.isHttpError(error)) {
|
||||
return next(error);
|
||||
}
|
||||
console.error("Error listing launcher groups:", error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Internal server error"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
78
server/routers/launcher/listLauncherResources.ts
Normal file
78
server/routers/launcher/listLauncherResources.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { response } from "@server/lib/response";
|
||||
import { getFirstString } from "@server/lib/requestParams";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
import { z } from "zod";
|
||||
import { listLauncherResourcesForUser } from "./launcherResourceAccess";
|
||||
import { launcherListQuerySchema } from "./types";
|
||||
|
||||
const listLauncherResourcesQuerySchema = launcherListQuerySchema.extend({
|
||||
groupKey: z.string().min(1)
|
||||
});
|
||||
|
||||
export async function listLauncherResources(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const orgId = getFirstString(req.params.orgId);
|
||||
const userId = req.user?.userId;
|
||||
|
||||
if (!userId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
|
||||
);
|
||||
}
|
||||
|
||||
if (!orgId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = listLauncherResourcesQuerySchema.safeParse(req.query);
|
||||
if (!parsed.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromZodError(parsed.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { resources, total } = await listLauncherResourcesForUser(
|
||||
orgId,
|
||||
userId,
|
||||
parsed.data
|
||||
);
|
||||
|
||||
return response(res, {
|
||||
data: {
|
||||
resources,
|
||||
pagination: {
|
||||
total,
|
||||
page: parsed.data.page,
|
||||
pageSize: parsed.data.pageSize
|
||||
}
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Launcher resources retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
if (createHttpError.isHttpError(error)) {
|
||||
return next(error);
|
||||
}
|
||||
console.error("Error listing launcher resources:", error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Internal server error"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
83
server/routers/launcher/listLauncherViews.ts
Normal file
83
server/routers/launcher/listLauncherViews.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { db, launcherViews } from "@server/db";
|
||||
import { response } from "@server/lib/response";
|
||||
import { getFirstString } from "@server/lib/requestParams";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { and, eq, isNull, or } from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { launcherViewConfigSchema, type LauncherViewRecord } from "./types";
|
||||
import { verifyLauncherOrgMembership } from "./launcherResourceAccess";
|
||||
|
||||
function mapViewRow(
|
||||
row: typeof launcherViews.$inferSelect
|
||||
): LauncherViewRecord {
|
||||
return {
|
||||
viewId: row.viewId,
|
||||
orgId: row.orgId,
|
||||
userId: row.userId,
|
||||
name: row.name,
|
||||
config: launcherViewConfigSchema.parse(JSON.parse(row.config)),
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
isOrgWide: row.userId == null
|
||||
};
|
||||
}
|
||||
|
||||
export async function listLauncherViews(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const orgId = getFirstString(req.params.orgId);
|
||||
const userId = req.user?.userId;
|
||||
|
||||
if (!userId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
|
||||
);
|
||||
}
|
||||
|
||||
if (!orgId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||
);
|
||||
}
|
||||
|
||||
await verifyLauncherOrgMembership(orgId, userId);
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(launcherViews)
|
||||
.where(
|
||||
and(
|
||||
eq(launcherViews.orgId, orgId),
|
||||
or(
|
||||
eq(launcherViews.userId, userId),
|
||||
isNull(launcherViews.userId)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
return response(res, {
|
||||
data: {
|
||||
views: rows.map(mapViewRow)
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Launcher views retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
if (createHttpError.isHttpError(error)) {
|
||||
return next(error);
|
||||
}
|
||||
console.error("Error listing launcher views:", error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Internal server error"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
129
server/routers/launcher/types.ts
Normal file
129
server/routers/launcher/types.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const LAUNCHER_UNLABELED_GROUP_KEY = "unlabeled";
|
||||
|
||||
export const launcherViewConfigSchema = z.object({
|
||||
groupBy: z.enum(["site", "label"]).default("site"),
|
||||
layout: z.enum(["grid", "list"]).default("grid"),
|
||||
sortBy: z.literal("name").default("name"),
|
||||
order: z.enum(["asc", "desc"]).default("asc"),
|
||||
showLabels: z.boolean().default(true),
|
||||
showSiteTags: z.boolean().default(true),
|
||||
showRecents: z.boolean().default(false).optional(),
|
||||
siteIds: z.array(z.number()).default([]),
|
||||
labelIds: z.array(z.number()).default([]),
|
||||
query: z.string().default("")
|
||||
});
|
||||
|
||||
export type LauncherViewConfig = z.infer<typeof launcherViewConfigSchema>;
|
||||
|
||||
export const defaultLauncherViewConfig: LauncherViewConfig =
|
||||
launcherViewConfigSchema.parse({});
|
||||
|
||||
export type LauncherLabel = {
|
||||
labelId: number;
|
||||
name: string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
export type LauncherSiteInfo = {
|
||||
siteId: number;
|
||||
name: string;
|
||||
type: string;
|
||||
online?: boolean;
|
||||
};
|
||||
|
||||
export type LauncherResource = {
|
||||
launcherResourceKey: string;
|
||||
resourceType: "public" | "site";
|
||||
resourceId: number;
|
||||
siteResourceId?: number;
|
||||
name: string;
|
||||
accessDisplay: string;
|
||||
accessCopyValue: string;
|
||||
accessUrl: string | null;
|
||||
iconUrl: string | null;
|
||||
enabled: boolean;
|
||||
mode: string;
|
||||
labels: LauncherLabel[];
|
||||
site?: LauncherSiteInfo;
|
||||
};
|
||||
|
||||
export type LauncherGroup = {
|
||||
groupKey: string;
|
||||
name: string;
|
||||
groupType: "site" | "label";
|
||||
itemCount: number;
|
||||
siteType?: string;
|
||||
siteOnline?: boolean;
|
||||
labelColor?: string;
|
||||
};
|
||||
|
||||
export type ListLauncherGroupsResponse = {
|
||||
groups: LauncherGroup[];
|
||||
pagination: {
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type ListLauncherResourcesResponse = {
|
||||
resources: LauncherResource[];
|
||||
pagination: {
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type LauncherViewRecord = {
|
||||
viewId: number;
|
||||
orgId: string;
|
||||
userId: string | null;
|
||||
name: string;
|
||||
config: LauncherViewConfig;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
isOrgWide: boolean;
|
||||
};
|
||||
|
||||
export type ListLauncherViewsResponse = {
|
||||
views: LauncherViewRecord[];
|
||||
};
|
||||
|
||||
export const launcherListQuerySchema = z.strictObject({
|
||||
pageSize: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.catch(20)
|
||||
.default(20),
|
||||
page: z.coerce.number().int().min(1).optional().catch(1).default(1),
|
||||
query: z.string().optional().default(""),
|
||||
groupBy: z.enum(["site", "label"]).optional().default("site"),
|
||||
groupKey: z.string().optional(),
|
||||
siteIds: z.string().optional(),
|
||||
labelIds: z.string().optional(),
|
||||
sort_by: z.literal("name").optional().default("name"),
|
||||
order: z.enum(["asc", "desc"]).optional().default("asc")
|
||||
});
|
||||
|
||||
export type LauncherListQuery = z.infer<typeof launcherListQuerySchema>;
|
||||
|
||||
export function parseIdListParam(value: string | undefined): number[] {
|
||||
if (!value?.trim()) {
|
||||
return [];
|
||||
}
|
||||
return value
|
||||
.split(",")
|
||||
.map((part) => Number.parseInt(part.trim(), 10))
|
||||
.filter((id) => Number.isFinite(id));
|
||||
}
|
||||
|
||||
export const DEFAULT_LAUNCHER_VIEW_ID = "default" as const;
|
||||
|
||||
export type LauncherViewSelection =
|
||||
| { type: "default" }
|
||||
| { type: "saved"; viewId: number };
|
||||
164
server/routers/launcher/updateLauncherView.ts
Normal file
164
server/routers/launcher/updateLauncherView.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { db, launcherViews } from "@server/db";
|
||||
import { response } from "@server/lib/response";
|
||||
import { getFirstString } from "@server/lib/requestParams";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import moment from "moment";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
isOrgAdminOrOwner,
|
||||
verifyLauncherOrgMembership
|
||||
} from "./launcherResourceAccess";
|
||||
import { launcherViewConfigSchema } from "./types";
|
||||
|
||||
const updateLauncherViewBodySchema = z.strictObject({
|
||||
name: z.string().min(1).max(128).optional(),
|
||||
config: launcherViewConfigSchema.optional(),
|
||||
orgWide: z.boolean().optional()
|
||||
});
|
||||
|
||||
export async function updateLauncherView(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const orgId = getFirstString(req.params.orgId);
|
||||
const viewId = Number.parseInt(
|
||||
getFirstString(req.params.viewId) ?? "",
|
||||
10
|
||||
);
|
||||
const userId = req.user?.userId;
|
||||
|
||||
if (!userId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
|
||||
);
|
||||
}
|
||||
|
||||
if (!orgId || !Number.isFinite(viewId)) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Invalid request parameters"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = updateLauncherViewBodySchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromZodError(parsed.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { userRoleIds } = await verifyLauncherOrgMembership(
|
||||
orgId,
|
||||
userId
|
||||
);
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(launcherViews)
|
||||
.where(
|
||||
and(
|
||||
eq(launcherViews.viewId, viewId),
|
||||
eq(launcherViews.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, "Launcher view not found")
|
||||
);
|
||||
}
|
||||
|
||||
const isPersonalView = existing.userId === userId;
|
||||
const isOrgWideView = existing.userId == null;
|
||||
const isAdmin = await isOrgAdminOrOwner(orgId, userId, userRoleIds);
|
||||
|
||||
if (!isPersonalView && !(isOrgWideView && isAdmin)) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"You do not have permission to update this view"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (parsed.data.orgWide === true && !isAdmin) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Only administrators can make views org-wide"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (parsed.data.orgWide === false && isOrgWideView && !isAdmin) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Only administrators can change org-wide views"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const nextUserId =
|
||||
parsed.data.orgWide === true
|
||||
? null
|
||||
: parsed.data.orgWide === false
|
||||
? userId
|
||||
: existing.userId;
|
||||
|
||||
const [updated] = await db
|
||||
.update(launcherViews)
|
||||
.set({
|
||||
name: parsed.data.name ?? existing.name,
|
||||
config: parsed.data.config
|
||||
? JSON.stringify(parsed.data.config)
|
||||
: existing.config,
|
||||
userId: nextUserId,
|
||||
updatedAt: moment().toISOString()
|
||||
})
|
||||
.where(eq(launcherViews.viewId, viewId))
|
||||
.returning();
|
||||
|
||||
return response(res, {
|
||||
data: {
|
||||
viewId: updated.viewId,
|
||||
orgId: updated.orgId,
|
||||
userId: updated.userId,
|
||||
name: updated.name,
|
||||
config: launcherViewConfigSchema.parse(
|
||||
JSON.parse(updated.config)
|
||||
),
|
||||
createdAt: updated.createdAt,
|
||||
updatedAt: updated.updatedAt,
|
||||
isOrgWide: updated.userId == null
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Launcher view updated successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
if (createHttpError.isHttpError(error)) {
|
||||
return next(error);
|
||||
}
|
||||
console.error("Error updating launcher view:", error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Internal server error"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Layout } from "@app/components/Layout";
|
||||
import MemberResourcesPortal from "@app/components/MemberResourcesPortal";
|
||||
import ResourceLauncher from "@app/components/resource-launcher/ResourceLauncher";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
@@ -18,7 +18,6 @@ type OrgPageProps = {
|
||||
export default async function OrgPage(props: OrgPageProps) {
|
||||
const params = await props.params;
|
||||
const orgId = params.orgId;
|
||||
const env = pullEnv();
|
||||
|
||||
if (!orgId) {
|
||||
redirect(`/`);
|
||||
@@ -40,12 +39,6 @@ export default async function OrgPage(props: OrgPageProps) {
|
||||
overview = res.data.data;
|
||||
} catch (e) {}
|
||||
|
||||
// If user is admin or owner, redirect to settings
|
||||
if (overview?.isAdmin || overview?.isOwner) {
|
||||
redirect(`/${orgId}/settings`);
|
||||
}
|
||||
|
||||
// For non-admin users, show the member resources portal
|
||||
let orgs: ListUserOrgsResponse["orgs"] = [];
|
||||
try {
|
||||
const getOrgs = cache(async () =>
|
||||
@@ -60,10 +53,21 @@ export default async function OrgPage(props: OrgPageProps) {
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
const isAdminOrOwner = Boolean(overview?.isAdmin || overview?.isOwner);
|
||||
|
||||
return (
|
||||
<UserProvider user={user}>
|
||||
<Layout orgId={orgId} navItems={[]} orgs={orgs}>
|
||||
{overview && <MemberResourcesPortal orgId={orgId} />}
|
||||
<Layout
|
||||
orgId={orgId}
|
||||
orgs={orgs}
|
||||
navItems={[]}
|
||||
showSidebar={false}
|
||||
launcherMode
|
||||
showViewAsAdmin={isAdminOrOwner}
|
||||
>
|
||||
{overview ? (
|
||||
<ResourceLauncher orgId={orgId} isAdmin={isAdminOrOwner} />
|
||||
) : null}
|
||||
</Layout>
|
||||
</UserProvider>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,8 @@ interface LayoutProps {
|
||||
showHeader?: boolean;
|
||||
showTopBar?: boolean;
|
||||
defaultSidebarCollapsed?: boolean;
|
||||
launcherMode?: boolean;
|
||||
showViewAsAdmin?: boolean;
|
||||
}
|
||||
|
||||
export async function Layout({
|
||||
@@ -26,7 +28,9 @@ export async function Layout({
|
||||
showSidebar = true,
|
||||
showHeader = true,
|
||||
showTopBar = true,
|
||||
defaultSidebarCollapsed = false
|
||||
defaultSidebarCollapsed = false,
|
||||
launcherMode = false,
|
||||
showViewAsAdmin = false
|
||||
}: LayoutProps) {
|
||||
const allCookies = await cookies();
|
||||
const sidebarStateCookie = allCookies.get("pangolin-sidebar-state")?.value;
|
||||
@@ -68,7 +72,15 @@ export async function Layout({
|
||||
)}
|
||||
|
||||
{/* Desktop header */}
|
||||
{showHeader && <LayoutHeader showTopBar={showTopBar} />}
|
||||
{showHeader && (
|
||||
<LayoutHeader
|
||||
showTopBar={showTopBar}
|
||||
launcherMode={launcherMode}
|
||||
orgId={orgId}
|
||||
orgs={orgs}
|
||||
showViewAsAdmin={showViewAsAdmin}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-y-auto p-3 md:p-6 w-full">
|
||||
|
||||
@@ -8,16 +8,31 @@ import { useTheme } from "next-themes";
|
||||
import BrandingLogo from "./BrandingLogo";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { LauncherOrgSelector } from "@app/components/resource-launcher/LauncherOrgSelector";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface LayoutHeaderProps {
|
||||
type LayoutHeaderProps = {
|
||||
showTopBar: boolean;
|
||||
}
|
||||
launcherMode?: boolean;
|
||||
orgId?: string;
|
||||
orgs?: ListUserOrgsResponse["orgs"];
|
||||
showViewAsAdmin?: boolean;
|
||||
};
|
||||
|
||||
export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
|
||||
export function LayoutHeader({
|
||||
showTopBar,
|
||||
launcherMode = false,
|
||||
orgId,
|
||||
orgs,
|
||||
showViewAsAdmin = false
|
||||
}: LayoutHeaderProps) {
|
||||
const { theme } = useTheme();
|
||||
const [path, setPath] = useState<string>("");
|
||||
const { env } = useEnvContext();
|
||||
const { isUnlocked } = useLicenseStatusContext();
|
||||
const t = useTranslations();
|
||||
|
||||
const logoWidth = isUnlocked()
|
||||
? env.branding.logo?.navbar?.width || 98
|
||||
@@ -53,16 +68,38 @@ export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
|
||||
<div className="relative z-10 px-6 py-2">
|
||||
<div className="container mx-auto max-w-12xl">
|
||||
<div className="h-16 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="/" className="flex items-center">
|
||||
<div className="flex items-center gap-5 min-w-0">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center shrink-0"
|
||||
>
|
||||
<BrandingLogo
|
||||
width={logoWidth}
|
||||
height={logoHeight}
|
||||
/>
|
||||
</Link>
|
||||
{/* {build === "saas" && (
|
||||
<Badge variant="secondary">Cloud Beta</Badge>
|
||||
)} */}
|
||||
{launcherMode ? (
|
||||
<>
|
||||
<LauncherOrgSelector
|
||||
orgId={orgId}
|
||||
orgs={orgs}
|
||||
/>
|
||||
{showViewAsAdmin && orgId ? (
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
className="p-0"
|
||||
asChild
|
||||
>
|
||||
<Link href={`/${orgId}/settings`}>
|
||||
{t(
|
||||
"resourceLauncherViewAsAdmin"
|
||||
)}
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{showTopBar && (
|
||||
|
||||
@@ -18,7 +18,13 @@ import { approvalQueries } from "@app/lib/queries";
|
||||
import { build } from "@server/build";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { ArrowRight, ExternalLink, PanelRightOpen, Server } from "lucide-react";
|
||||
import {
|
||||
ArrowRight,
|
||||
ExternalLink,
|
||||
PanelRightOpen,
|
||||
Server,
|
||||
SquareMousePointer
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
@@ -130,6 +136,13 @@ export function LayoutSidebar({
|
||||
const showTrial =
|
||||
build === "saas" && Boolean(orgId) && subscriptionContext?.isTrial;
|
||||
|
||||
const isSettingsPage = Boolean(
|
||||
orgId && pathname?.includes(`/${orgId}/settings`)
|
||||
);
|
||||
const canViewResourceLauncher = Boolean(
|
||||
currentOrg?.isAdmin || currentOrg?.isOwner
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -152,6 +165,46 @@ export function LayoutSidebar({
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto relative">
|
||||
<div className="px-2 pt-3">
|
||||
{!isAdminPage &&
|
||||
isSettingsPage &&
|
||||
canViewResourceLauncher &&
|
||||
orgId && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
isSidebarCollapsed ? "mb-4" : "mb-1"
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
href={`/${orgId}`}
|
||||
className={cn(
|
||||
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 rounded-md",
|
||||
isSidebarCollapsed
|
||||
? "px-2 py-2 justify-center"
|
||||
: "px-3 py-1.5"
|
||||
)}
|
||||
title={
|
||||
isSidebarCollapsed
|
||||
? t("resourceLauncherTitle")
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground",
|
||||
!isSidebarCollapsed && "mr-3"
|
||||
)}
|
||||
>
|
||||
<SquareMousePointer className="h-4 w-4" />
|
||||
</span>
|
||||
{!isSidebarCollapsed && (
|
||||
<span className="flex-1">
|
||||
{t("resourceLauncherTitle")}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{!isAdminPage && user.serverAdmin && (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -38,6 +38,21 @@ export type LabelsSelectorProps = {
|
||||
toggleLabel: (newlabel: SelectedLabel, action: "detach" | "attach") => void;
|
||||
};
|
||||
|
||||
export function formatLabelsSelectorLabel(
|
||||
selectedLabels: SelectedLabel[],
|
||||
t: (key: string, values?: { count: number }) => string
|
||||
): string {
|
||||
if (selectedLabels.length === 0) {
|
||||
return t("selectLabels");
|
||||
}
|
||||
if (selectedLabels.length === 1) {
|
||||
return selectedLabels[0]!.name;
|
||||
}
|
||||
return t("labelsSelectorLabelsCount", {
|
||||
count: selectedLabels.length
|
||||
});
|
||||
}
|
||||
|
||||
export const LABEL_COLORS = {
|
||||
red: "#ff6467",
|
||||
green: "#05df72",
|
||||
|
||||
43
src/components/resource-launcher/LauncherCopyIcon.tsx
Normal file
43
src/components/resource-launcher/LauncherCopyIcon.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
|
||||
type LauncherCopyIconProps = {
|
||||
text: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function LauncherCopyIcon({ text, className }: LauncherCopyIconProps) {
|
||||
const t = useTranslations();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex size-4 shrink-0 items-center justify-center text-muted-foreground transition-colors hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="size-4" />
|
||||
)}
|
||||
<span className="sr-only">{t("copyText")}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
142
src/components/resource-launcher/LauncherFilterPopover.tsx
Normal file
142
src/components/resource-launcher/LauncherFilterPopover.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
formatMultiSitesSelectorLabel,
|
||||
MultiSitesSelector
|
||||
} from "@app/components/multi-site-selector";
|
||||
import {
|
||||
formatLabelsSelectorLabel,
|
||||
LabelsSelector,
|
||||
type SelectedLabel
|
||||
} from "@app/components/labels-selector";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ChevronsUpDown, Funnel } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import type { Selectedsite } from "@app/components/site-selector";
|
||||
|
||||
type LauncherFilterPopoverProps = {
|
||||
orgId: string;
|
||||
selectedSites: Selectedsite[];
|
||||
selectedLabels: SelectedLabel[];
|
||||
onSitesChange: (sites: Selectedsite[]) => void;
|
||||
onLabelsChange: (labels: SelectedLabel[]) => void;
|
||||
};
|
||||
|
||||
export function LauncherFilterPopover({
|
||||
orgId,
|
||||
selectedSites,
|
||||
selectedLabels,
|
||||
onSitesChange,
|
||||
onLabelsChange
|
||||
}: LauncherFilterPopoverProps) {
|
||||
const t = useTranslations();
|
||||
const [sitesOpen, setSitesOpen] = useState(false);
|
||||
const [labelsOpen, setLabelsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Popover modal={false}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="shrink-0">
|
||||
<Funnel className="size-4" />
|
||||
<span className="sr-only">
|
||||
{t("resourceLauncherFilter")}
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-72">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-semibold">{t("sites")}</p>
|
||||
<Popover open={sitesOpen} onOpenChange={setSitesOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full justify-between font-normal",
|
||||
selectedSites.length === 0 &&
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-left">
|
||||
{formatMultiSitesSelectorLabel(
|
||||
selectedSites,
|
||||
t
|
||||
)}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
align="start"
|
||||
>
|
||||
<MultiSitesSelector
|
||||
orgId={orgId}
|
||||
selectedSites={selectedSites}
|
||||
onSelectionChange={onSitesChange}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-semibold">{t("labels")}</p>
|
||||
<Popover open={labelsOpen} onOpenChange={setLabelsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full justify-between font-normal",
|
||||
selectedLabels.length === 0 &&
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-left">
|
||||
{formatLabelsSelectorLabel(
|
||||
selectedLabels,
|
||||
t
|
||||
)}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
align="start"
|
||||
>
|
||||
<LabelsSelector
|
||||
orgId={orgId}
|
||||
selectedLabels={selectedLabels}
|
||||
toggleLabel={(label, action) => {
|
||||
if (action === "attach") {
|
||||
onLabelsChange([
|
||||
...selectedLabels,
|
||||
label
|
||||
]);
|
||||
} else {
|
||||
onLabelsChange(
|
||||
selectedLabels.filter(
|
||||
(item) =>
|
||||
item.labelId !==
|
||||
label.labelId
|
||||
)
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
201
src/components/resource-launcher/LauncherGroupList.tsx
Normal file
201
src/components/resource-launcher/LauncherGroupList.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
"use client";
|
||||
|
||||
import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage";
|
||||
import { readLauncherGroupOpen } from "@app/lib/launcherLocalStorage";
|
||||
import { launcherQueries } from "@app/lib/queries";
|
||||
import type { LauncherViewConfig } from "@server/routers/launcher/types";
|
||||
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { LauncherGroupSection } from "./LauncherGroupSection";
|
||||
|
||||
type LauncherGroupListProps = {
|
||||
orgId: string;
|
||||
activeViewId: LauncherActiveViewId;
|
||||
config: LauncherViewConfig;
|
||||
searchQuery: string;
|
||||
};
|
||||
|
||||
function buildResourceFilters(
|
||||
config: LauncherViewConfig,
|
||||
searchQuery: string,
|
||||
groupKey: string
|
||||
) {
|
||||
return {
|
||||
query: searchQuery,
|
||||
groupBy: config.groupBy,
|
||||
groupKey,
|
||||
siteIds: config.siteIds,
|
||||
labelIds: config.labelIds,
|
||||
sort_by: config.sortBy,
|
||||
order: config.order,
|
||||
pageSize: 20
|
||||
};
|
||||
}
|
||||
|
||||
export function LauncherGroupList({
|
||||
orgId,
|
||||
activeViewId,
|
||||
config,
|
||||
searchQuery
|
||||
}: LauncherGroupListProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isPrefetching, setIsPrefetching] = useState(false);
|
||||
const prefetchBatchKeyRef = useRef<string | null>(null);
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } =
|
||||
useInfiniteQuery({
|
||||
...launcherQueries.groups(orgId, {
|
||||
query: searchQuery,
|
||||
groupBy: config.groupBy,
|
||||
siteIds: config.siteIds,
|
||||
labelIds: config.labelIds,
|
||||
sort_by: config.sortBy,
|
||||
order: config.order,
|
||||
pageSize: 20
|
||||
})
|
||||
});
|
||||
|
||||
const groups = data?.pages.flatMap((page) => page.groups) ?? [];
|
||||
|
||||
const batchKey = useMemo(
|
||||
() =>
|
||||
JSON.stringify({
|
||||
activeViewId,
|
||||
searchQuery,
|
||||
groupBy: config.groupBy,
|
||||
siteIds: config.siteIds,
|
||||
labelIds: config.labelIds,
|
||||
sortBy: config.sortBy,
|
||||
order: config.order
|
||||
}),
|
||||
[
|
||||
activeViewId,
|
||||
config.groupBy,
|
||||
config.labelIds,
|
||||
config.order,
|
||||
config.siteIds,
|
||||
config.sortBy,
|
||||
searchQuery
|
||||
]
|
||||
);
|
||||
|
||||
const openGroupKeys = useMemo(
|
||||
() =>
|
||||
groups
|
||||
.filter((group) =>
|
||||
readLauncherGroupOpen(
|
||||
orgId,
|
||||
activeViewId,
|
||||
config.groupBy,
|
||||
group.groupKey,
|
||||
true
|
||||
)
|
||||
)
|
||||
.map((group) => group.groupKey),
|
||||
[activeViewId, config.groupBy, groups, orgId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (openGroupKeys.length === 0) {
|
||||
prefetchBatchKeyRef.current = batchKey;
|
||||
setIsPrefetching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (prefetchBatchKeyRef.current === batchKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIsPrefetching(true);
|
||||
|
||||
void Promise.all(
|
||||
openGroupKeys.map((groupKey) =>
|
||||
queryClient.prefetchInfiniteQuery(
|
||||
launcherQueries.resources(
|
||||
orgId,
|
||||
buildResourceFilters(config, searchQuery, groupKey)
|
||||
)
|
||||
)
|
||||
)
|
||||
).finally(() => {
|
||||
if (!cancelled) {
|
||||
prefetchBatchKeyRef.current = batchKey;
|
||||
setIsPrefetching(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
batchKey,
|
||||
config,
|
||||
isLoading,
|
||||
openGroupKeys,
|
||||
orgId,
|
||||
queryClient,
|
||||
searchQuery
|
||||
]);
|
||||
|
||||
const isBatchPending = prefetchBatchKeyRef.current !== batchKey;
|
||||
const isBodyLoading =
|
||||
isLoading ||
|
||||
(isBatchPending &&
|
||||
openGroupKeys.length > 0 &&
|
||||
(isPrefetching || !isLoading));
|
||||
|
||||
useEffect(() => {
|
||||
const node = loadMoreRef.current;
|
||||
if (!node || !hasNextPage || isBodyLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting && !isFetchingNextPage) {
|
||||
void fetchNextPage();
|
||||
}
|
||||
},
|
||||
{ rootMargin: "200px" }
|
||||
);
|
||||
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, [fetchNextPage, hasNextPage, isBodyLoading, isFetchingNextPage]);
|
||||
|
||||
if (isBodyLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{groups.map((group) => (
|
||||
<LauncherGroupSection
|
||||
key={group.groupKey}
|
||||
orgId={orgId}
|
||||
activeViewId={activeViewId}
|
||||
group={group}
|
||||
config={config}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
))}
|
||||
<div ref={loadMoreRef} className="h-4" />
|
||||
{isFetchingNextPage ? (
|
||||
<div className="flex justify-center py-2">
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
164
src/components/resource-launcher/LauncherGroupSection.tsx
Normal file
164
src/components/resource-launcher/LauncherGroupSection.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent
|
||||
} from "@app/components/ui/collapsible";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import {
|
||||
readLauncherGroupOpen,
|
||||
writeLauncherGroupOpen,
|
||||
type LauncherActiveViewId
|
||||
} from "@app/lib/launcherLocalStorage";
|
||||
import { launcherQueries } from "@app/lib/queries";
|
||||
import type {
|
||||
LauncherGroup,
|
||||
LauncherViewConfig
|
||||
} from "@server/routers/launcher/types";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { LauncherGroupTrigger } from "./LauncherGroupTrigger";
|
||||
import { LauncherResourceGrid } from "./LauncherResourceGrid";
|
||||
import { LauncherResourceList } from "./LauncherResourceList";
|
||||
|
||||
type LauncherGroupSectionProps = {
|
||||
orgId: string;
|
||||
activeViewId: LauncherActiveViewId;
|
||||
group: LauncherGroup;
|
||||
config: LauncherViewConfig;
|
||||
searchQuery: string;
|
||||
defaultOpen?: boolean;
|
||||
};
|
||||
|
||||
export function LauncherGroupSection({
|
||||
orgId,
|
||||
activeViewId,
|
||||
group,
|
||||
config,
|
||||
searchQuery,
|
||||
defaultOpen = true
|
||||
}: LauncherGroupSectionProps) {
|
||||
const t = useTranslations();
|
||||
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(() =>
|
||||
readLauncherGroupOpen(
|
||||
orgId,
|
||||
activeViewId,
|
||||
config.groupBy,
|
||||
group.groupKey,
|
||||
defaultOpen
|
||||
)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setIsOpen(
|
||||
readLauncherGroupOpen(
|
||||
orgId,
|
||||
activeViewId,
|
||||
config.groupBy,
|
||||
group.groupKey,
|
||||
defaultOpen
|
||||
)
|
||||
);
|
||||
}, [activeViewId, config.groupBy, defaultOpen, group.groupKey, orgId]);
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setIsOpen(open);
|
||||
writeLauncherGroupOpen(
|
||||
orgId,
|
||||
activeViewId,
|
||||
config.groupBy,
|
||||
group.groupKey,
|
||||
open
|
||||
);
|
||||
};
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } =
|
||||
useInfiniteQuery({
|
||||
...launcherQueries.resources(orgId, {
|
||||
query: searchQuery,
|
||||
groupBy: config.groupBy,
|
||||
groupKey: group.groupKey,
|
||||
siteIds: config.siteIds,
|
||||
labelIds: config.labelIds,
|
||||
sort_by: config.sortBy,
|
||||
order: config.order,
|
||||
pageSize: 20
|
||||
}),
|
||||
enabled: isOpen
|
||||
});
|
||||
|
||||
const resources = data?.pages.flatMap((page) => page.resources) ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
const node = loadMoreRef.current;
|
||||
if (!node || !hasNextPage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting && !isFetchingNextPage) {
|
||||
void fetchNextPage();
|
||||
}
|
||||
},
|
||||
{ rootMargin: "200px" }
|
||||
);
|
||||
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
||||
|
||||
const groupTitle =
|
||||
group.groupKey === "unlabeled"
|
||||
? t("resourceLauncherUnlabeled")
|
||||
: group.name;
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
open={isOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
className="flex w-full flex-col gap-2.5"
|
||||
>
|
||||
<LauncherGroupTrigger
|
||||
group={group}
|
||||
title={groupTitle}
|
||||
isOpen={isOpen}
|
||||
/>
|
||||
|
||||
<CollapsibleContent className="w-full">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-10 text-muted-foreground">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
</div>
|
||||
) : resources.length === 0 ? (
|
||||
<p className="py-4 text-sm text-muted-foreground">
|
||||
{t("resourceLauncherNoResourcesInGroup")}
|
||||
</p>
|
||||
) : config.layout === "grid" ? (
|
||||
<LauncherResourceGrid
|
||||
resources={resources}
|
||||
showLabels={config.showLabels}
|
||||
/>
|
||||
) : (
|
||||
<LauncherResourceList
|
||||
resources={resources}
|
||||
showLabels={config.showLabels}
|
||||
showSiteTags={config.showSiteTags}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
ref={loadMoreRef}
|
||||
className={cn("h-4", !hasNextPage && "hidden")}
|
||||
/>
|
||||
{isFetchingNextPage ? (
|
||||
<div className="flex justify-center py-2">
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : null}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
67
src/components/resource-launcher/LauncherGroupTrigger.tsx
Normal file
67
src/components/resource-launcher/LauncherGroupTrigger.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { CollapsibleTrigger } from "@app/components/ui/collapsible";
|
||||
import type { LauncherGroup } from "@server/routers/launcher/types";
|
||||
import { ChevronDown, ChevronLeft } from "lucide-react";
|
||||
|
||||
type LauncherGroupTriggerProps = {
|
||||
group: LauncherGroup;
|
||||
title: string;
|
||||
isOpen: boolean;
|
||||
};
|
||||
|
||||
function LauncherGroupStatusDot({ group }: { group: LauncherGroup }) {
|
||||
if (group.groupType === "label") {
|
||||
return (
|
||||
<span
|
||||
className="size-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: group.labelColor }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (group.groupType === "site") {
|
||||
if (
|
||||
(group.siteType === "newt" || group.siteType === "wireguard") &&
|
||||
typeof group.siteOnline === "boolean"
|
||||
) {
|
||||
return (
|
||||
<span
|
||||
className={
|
||||
group.siteOnline
|
||||
? "size-2 shrink-0 rounded-full bg-green-500"
|
||||
: "size-2 shrink-0 rounded-full bg-neutral-500"
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className="size-2 shrink-0 rounded-full bg-neutral-500" />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function LauncherGroupTrigger({
|
||||
group,
|
||||
title,
|
||||
isOpen
|
||||
}: LauncherGroupTriggerProps) {
|
||||
return (
|
||||
<CollapsibleTrigger className="flex w-full items-center gap-2.5 rounded-md bg-accent px-4 py-2.5 text-left transition-colors cursor-pointer">
|
||||
{group.groupType === "site" || group.groupType === "label" ? (
|
||||
<LauncherGroupStatusDot group={group} />
|
||||
) : null}
|
||||
<span className="flex min-w-0 items-center gap-2.5 text-sm font-semibold text-foreground">
|
||||
<span className="truncate">
|
||||
{title} ({group.itemCount})
|
||||
</span>
|
||||
{isOpen ? (
|
||||
<ChevronDown className="size-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronLeft className="size-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
</span>
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
}
|
||||
175
src/components/resource-launcher/LauncherLabelsRow.tsx
Normal file
175
src/components/resource-launcher/LauncherLabelsRow.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
import type { LauncherLabel } from "@server/routers/launcher/types";
|
||||
import { LabelBadge } from "@app/components/label-badge";
|
||||
import { LabelOverflowBadge } from "@app/components/label-overflow-badge";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
|
||||
const MAX_LABEL_ROWS = 2;
|
||||
const SINGLE_ROW_MAX_LABELS = 5;
|
||||
|
||||
type LauncherLabelsRowProps = {
|
||||
labels: LauncherLabel[];
|
||||
className?: string;
|
||||
variant?: "wrap" | "single-row";
|
||||
};
|
||||
|
||||
function countFlexRows(container: HTMLElement): number {
|
||||
const rowTops = new Set<number>();
|
||||
|
||||
for (const child of container.children) {
|
||||
const element = child as HTMLElement;
|
||||
if (element.style.display === "none") {
|
||||
continue;
|
||||
}
|
||||
rowTops.add(element.offsetTop);
|
||||
}
|
||||
|
||||
return rowTops.size;
|
||||
}
|
||||
|
||||
export function LauncherLabelsRow({
|
||||
labels,
|
||||
className,
|
||||
variant = "wrap"
|
||||
}: LauncherLabelsRowProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const measureRef = useRef<HTMLDivElement>(null);
|
||||
const [visibleCount, setVisibleCount] = useState(labels.length);
|
||||
|
||||
const labelKey = labels.map((label) => label.labelId).join(",");
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (variant === "single-row") {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = containerRef.current;
|
||||
const measure = measureRef.current;
|
||||
if (!container || !measure || labels.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const recompute = () => {
|
||||
const width = container.clientWidth;
|
||||
if (width <= 0) {
|
||||
setVisibleCount(labels.length);
|
||||
return;
|
||||
}
|
||||
|
||||
measure.style.width = `${width}px`;
|
||||
|
||||
const labelNodes = measure.querySelectorAll<HTMLElement>(
|
||||
"[data-measure-label]"
|
||||
);
|
||||
const overflowNode = measure.querySelector<HTMLElement>(
|
||||
"[data-measure-overflow]"
|
||||
);
|
||||
|
||||
const fits = (visible: number) => {
|
||||
labelNodes.forEach((node, index) => {
|
||||
node.style.display = index < visible ? "" : "none";
|
||||
});
|
||||
|
||||
if (overflowNode) {
|
||||
const overflowCount = labels.length - visible;
|
||||
if (overflowCount > 0) {
|
||||
overflowNode.style.display = "";
|
||||
} else {
|
||||
overflowNode.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
return countFlexRows(measure) <= MAX_LABEL_ROWS;
|
||||
};
|
||||
|
||||
let best = 0;
|
||||
for (let visible = labels.length; visible >= 0; visible--) {
|
||||
if (fits(visible)) {
|
||||
best = visible;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setVisibleCount(best);
|
||||
};
|
||||
|
||||
recompute();
|
||||
|
||||
const observer = new ResizeObserver(recompute);
|
||||
observer.observe(container);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [labelKey, labels, variant]);
|
||||
|
||||
if (labels.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedVisibleCount =
|
||||
variant === "single-row"
|
||||
? Math.min(labels.length, SINGLE_ROW_MAX_LABELS)
|
||||
: visibleCount;
|
||||
const visibleLabels = labels.slice(0, resolvedVisibleCount);
|
||||
const overflowLabels = labels.slice(resolvedVisibleCount);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn("relative min-w-0 w-full", className)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1",
|
||||
variant === "single-row" ? "flex-nowrap" : "flex-wrap"
|
||||
)}
|
||||
>
|
||||
{visibleLabels.map((label) => (
|
||||
<LabelBadge
|
||||
key={label.labelId}
|
||||
name={label.name}
|
||||
color={label.color}
|
||||
displayOnly
|
||||
className="shrink-0"
|
||||
/>
|
||||
))}
|
||||
{overflowLabels.length > 0 ? (
|
||||
<LabelOverflowBadge
|
||||
labels={overflowLabels.map((label) => ({
|
||||
color: label.color,
|
||||
name: label.name
|
||||
}))}
|
||||
displayOnly
|
||||
className="shrink-0"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{variant === "wrap" ? (
|
||||
<div
|
||||
ref={measureRef}
|
||||
className="pointer-events-none invisible absolute left-0 top-0 flex flex-wrap items-center gap-1"
|
||||
aria-hidden
|
||||
>
|
||||
{labels.map((label) => (
|
||||
<span key={label.labelId} data-measure-label>
|
||||
<LabelBadge
|
||||
name={label.name}
|
||||
color={label.color}
|
||||
displayOnly
|
||||
className="shrink-0"
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
<span
|
||||
data-measure-overflow
|
||||
className="inline-flex shrink-0"
|
||||
>
|
||||
<LabelOverflowBadge labels={labels} displayOnly />
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
src/components/resource-launcher/LauncherOrgSelector.tsx
Normal file
114
src/components/resource-launcher/LauncherOrgSelector.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "@app/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { Check, ChevronDown, ChevronsUpDown } from "lucide-react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
|
||||
type LauncherOrgSelectorProps = {
|
||||
orgId?: string;
|
||||
orgs?: ListUserOrgsResponse["orgs"];
|
||||
};
|
||||
|
||||
export function LauncherOrgSelector({ orgId, orgs }: LauncherOrgSelectorProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const t = useTranslations();
|
||||
|
||||
const selectedOrg = orgs?.find((org) => org.orgId === orgId);
|
||||
|
||||
const sortedOrgs = useMemo(() => {
|
||||
if (!orgs?.length) {
|
||||
return orgs ?? [];
|
||||
}
|
||||
return [...orgs].sort((a, b) => {
|
||||
const aPrimary = Boolean(a.isPrimaryOrg);
|
||||
const bPrimary = Boolean(b.isPrimaryOrg);
|
||||
if (aPrimary && !bPrimary) {
|
||||
return -1;
|
||||
}
|
||||
if (!aPrimary && bPrimary) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}, [orgs]);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
className="inline-flex items-center gap-1 p-0"
|
||||
variant="text"
|
||||
size="sm"
|
||||
>
|
||||
<span className="truncate max-w-[200px]">
|
||||
{selectedOrg?.name ?? t("noneSelected")}
|
||||
</span>
|
||||
<ChevronDown className="size-4 shrink-0" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[320px] p-0" align="start">
|
||||
<Command className="rounded-lg border-0">
|
||||
<CommandInput placeholder={t("searchPlaceholder")} />
|
||||
<CommandList className="max-h-[280px]">
|
||||
<CommandEmpty>{t("orgNotFound2")}</CommandEmpty>
|
||||
<CommandGroup heading={t("orgs")}>
|
||||
{sortedOrgs.map((org) => (
|
||||
<CommandItem
|
||||
key={org.orgId}
|
||||
onSelect={() => {
|
||||
setOpen(false);
|
||||
const newPath = pathname.includes(
|
||||
"/settings/"
|
||||
)
|
||||
? pathname.replace(
|
||||
/^\/[^/]+/,
|
||||
`/${org.orgId}`
|
||||
)
|
||||
: `/${org.orgId}`;
|
||||
router.push(newPath);
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<span className="font-medium truncate text-sm">
|
||||
{org.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground font-mono truncate">
|
||||
{org.orgId}
|
||||
</span>
|
||||
</div>
|
||||
<Check
|
||||
className={cn(
|
||||
"h-4 w-4 text-primary shrink-0",
|
||||
orgId === org.orgId
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
69
src/components/resource-launcher/LauncherResourceAccess.tsx
Normal file
69
src/components/resource-launcher/LauncherResourceAccess.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { isSafeUrlForLink } from "@app/lib/launcherResourceAccess";
|
||||
import Link from "next/link";
|
||||
import { LauncherCopyIcon } from "./LauncherCopyIcon";
|
||||
|
||||
type LauncherResourceAccessProps = {
|
||||
accessDisplay: string;
|
||||
accessCopyValue: string;
|
||||
accessUrl?: string | null;
|
||||
variant: "grid" | "list";
|
||||
};
|
||||
|
||||
export function LauncherResourceAccess({
|
||||
accessDisplay,
|
||||
accessCopyValue,
|
||||
accessUrl,
|
||||
variant
|
||||
}: LauncherResourceAccessProps) {
|
||||
if (!accessDisplay) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const href = accessUrl ?? undefined;
|
||||
const canLink = href && isSafeUrlForLink(href);
|
||||
const copyValue = canLink ? href : accessCopyValue;
|
||||
|
||||
if (variant === "list") {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2.5">
|
||||
{canLink ? (
|
||||
<Link
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="min-w-0 truncate text-sm text-muted-foreground hover:underline"
|
||||
>
|
||||
{accessDisplay}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="min-w-0 truncate text-sm text-muted-foreground">
|
||||
{accessDisplay}
|
||||
</span>
|
||||
)}
|
||||
<LauncherCopyIcon text={copyValue} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full min-w-0 items-center gap-2.5">
|
||||
{canLink ? (
|
||||
<Link
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="min-w-0 flex-1 truncate text-sm text-muted-foreground hover:underline"
|
||||
>
|
||||
{accessDisplay}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-muted-foreground">
|
||||
{accessDisplay}
|
||||
</span>
|
||||
)}
|
||||
<LauncherCopyIcon text={copyValue} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
src/components/resource-launcher/LauncherResourceCard.tsx
Normal file
73
src/components/resource-launcher/LauncherResourceCard.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@app/lib/cn";
|
||||
import type { LauncherResource } from "@server/routers/launcher/types";
|
||||
import { LauncherLabelsRow } from "./LauncherLabelsRow";
|
||||
import { LauncherResourceAccess } from "./LauncherResourceAccess";
|
||||
import { LauncherResourceIcon } from "./LauncherResourceIcon";
|
||||
import {
|
||||
getLauncherResourceClickProps,
|
||||
useLauncherResourceAction
|
||||
} from "./useLauncherResourceAction";
|
||||
|
||||
type LauncherResourceCardProps = {
|
||||
resource: LauncherResource;
|
||||
showLabels: boolean;
|
||||
};
|
||||
|
||||
export function LauncherResourceCard({
|
||||
resource,
|
||||
showLabels
|
||||
}: LauncherResourceCardProps) {
|
||||
const hasIcon = Boolean(resource.iconUrl);
|
||||
const { handleAction, isClickable } = useLauncherResourceAction({
|
||||
accessUrl: resource.accessUrl,
|
||||
accessCopyValue: resource.accessCopyValue
|
||||
});
|
||||
const clickProps = getLauncherResourceClickProps(handleAction, isClickable);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 flex-col gap-2.5 overflow-hidden rounded-xl border border-border bg-background p-4 transition-colors",
|
||||
isClickable && "hover:bg-accent/40",
|
||||
clickProps.className
|
||||
)}
|
||||
onClick={clickProps.onClick}
|
||||
onKeyDown={clickProps.onKeyDown}
|
||||
role={clickProps.role}
|
||||
tabIndex={clickProps.tabIndex}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center",
|
||||
hasIcon ? "gap-5" : "gap-0"
|
||||
)}
|
||||
>
|
||||
{hasIcon ? (
|
||||
<LauncherResourceIcon
|
||||
iconUrl={resource.iconUrl}
|
||||
name={resource.name}
|
||||
variant="grid"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="truncate text-sm font-semibold text-foreground">
|
||||
{resource.name}
|
||||
</div>
|
||||
<LauncherResourceAccess
|
||||
accessDisplay={resource.accessDisplay}
|
||||
accessCopyValue={resource.accessCopyValue}
|
||||
accessUrl={resource.accessUrl}
|
||||
variant="grid"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showLabels && resource.labels.length > 0 ? (
|
||||
<LauncherLabelsRow labels={resource.labels} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
src/components/resource-launcher/LauncherResourceGrid.tsx
Normal file
26
src/components/resource-launcher/LauncherResourceGrid.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import type { LauncherResource } from "@server/routers/launcher/types";
|
||||
import { LauncherResourceCard } from "./LauncherResourceCard";
|
||||
|
||||
type LauncherResourceGridProps = {
|
||||
resources: LauncherResource[];
|
||||
showLabels: boolean;
|
||||
};
|
||||
|
||||
export function LauncherResourceGrid({
|
||||
resources,
|
||||
showLabels
|
||||
}: LauncherResourceGridProps) {
|
||||
return (
|
||||
<div className="grid w-full grid-cols-1 gap-2.5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 [&>*]:min-w-0">
|
||||
{resources.map((resource) => (
|
||||
<LauncherResourceCard
|
||||
key={resource.launcherResourceKey}
|
||||
resource={resource}
|
||||
showLabels={showLabels}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
src/components/resource-launcher/LauncherResourceIcon.tsx
Normal file
45
src/components/resource-launcher/LauncherResourceIcon.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@app/lib/cn";
|
||||
|
||||
type LauncherResourceIconProps = {
|
||||
iconUrl?: string | null;
|
||||
name: string;
|
||||
className?: string;
|
||||
variant?: "grid" | "list";
|
||||
};
|
||||
|
||||
export function LauncherResourceIcon({
|
||||
iconUrl,
|
||||
name,
|
||||
className,
|
||||
variant = "grid"
|
||||
}: LauncherResourceIconProps) {
|
||||
const dimension = variant === "list" ? "size-5" : "size-10";
|
||||
|
||||
if (iconUrl) {
|
||||
return (
|
||||
<img
|
||||
src={iconUrl}
|
||||
alt={name}
|
||||
className={cn(dimension, "shrink-0 object-cover", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "list") {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
dimension,
|
||||
"flex shrink-0 items-center justify-center text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="text-sm font-semibold">-</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
30
src/components/resource-launcher/LauncherResourceList.tsx
Normal file
30
src/components/resource-launcher/LauncherResourceList.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import type { LauncherResource } from "@server/routers/launcher/types";
|
||||
import { LauncherResourceRow } from "./LauncherResourceRow";
|
||||
|
||||
type LauncherResourceListProps = {
|
||||
resources: LauncherResource[];
|
||||
showLabels: boolean;
|
||||
showSiteTags: boolean;
|
||||
};
|
||||
|
||||
export function LauncherResourceList({
|
||||
resources,
|
||||
showLabels,
|
||||
showSiteTags
|
||||
}: LauncherResourceListProps) {
|
||||
return (
|
||||
<div className="flex w-full flex-col">
|
||||
{resources.map((resource, index) => (
|
||||
<LauncherResourceRow
|
||||
key={resource.launcherResourceKey}
|
||||
resource={resource}
|
||||
showLabels={showLabels}
|
||||
showSiteTags={showSiteTags}
|
||||
isLast={index === resources.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
87
src/components/resource-launcher/LauncherResourceRow.tsx
Normal file
87
src/components/resource-launcher/LauncherResourceRow.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { LabelBadge } from "@app/components/label-badge";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import type { LauncherResource } from "@server/routers/launcher/types";
|
||||
import { LauncherLabelsRow } from "./LauncherLabelsRow";
|
||||
import { LauncherResourceAccess } from "./LauncherResourceAccess";
|
||||
import { LauncherResourceIcon } from "./LauncherResourceIcon";
|
||||
import {
|
||||
getLauncherResourceClickProps,
|
||||
useLauncherResourceAction
|
||||
} from "./useLauncherResourceAction";
|
||||
|
||||
type LauncherResourceRowProps = {
|
||||
resource: LauncherResource;
|
||||
showLabels: boolean;
|
||||
showSiteTags: boolean;
|
||||
isLast?: boolean;
|
||||
};
|
||||
|
||||
export function LauncherResourceRow({
|
||||
resource,
|
||||
showLabels,
|
||||
showSiteTags,
|
||||
isLast = false
|
||||
}: LauncherResourceRowProps) {
|
||||
const hasTags =
|
||||
(showSiteTags && resource.site) ||
|
||||
(showLabels && resource.labels.length > 0);
|
||||
const { handleAction, isClickable } = useLauncherResourceAction({
|
||||
accessUrl: resource.accessUrl,
|
||||
accessCopyValue: resource.accessCopyValue
|
||||
});
|
||||
const clickProps = getLauncherResourceClickProps(handleAction, isClickable);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 p-4 transition-colors",
|
||||
isLast ? undefined : "border-b border-border",
|
||||
isClickable && "hover:bg-accent/40",
|
||||
clickProps.className
|
||||
)}
|
||||
onClick={clickProps.onClick}
|
||||
onKeyDown={clickProps.onKeyDown}
|
||||
role={clickProps.role}
|
||||
tabIndex={clickProps.tabIndex}
|
||||
>
|
||||
<LauncherResourceIcon
|
||||
iconUrl={resource.iconUrl}
|
||||
name={resource.name}
|
||||
variant="list"
|
||||
/>
|
||||
|
||||
<span className="shrink-0 text-sm font-semibold text-foreground">
|
||||
{resource.name}
|
||||
</span>
|
||||
|
||||
<LauncherResourceAccess
|
||||
accessDisplay={resource.accessDisplay}
|
||||
accessCopyValue={resource.accessCopyValue}
|
||||
accessUrl={resource.accessUrl}
|
||||
variant="list"
|
||||
/>
|
||||
|
||||
{hasTags ? (
|
||||
<div className="ml-auto flex min-w-0 max-w-md shrink items-center justify-end gap-1">
|
||||
{showSiteTags && resource.site ? (
|
||||
<LabelBadge
|
||||
name={resource.site.name}
|
||||
color="#a1a1aa"
|
||||
displayOnly
|
||||
className="shrink-0"
|
||||
/>
|
||||
) : null}
|
||||
{showLabels ? (
|
||||
<LauncherLabelsRow
|
||||
labels={resource.labels}
|
||||
variant="single-row"
|
||||
className="w-auto shrink-0 justify-end"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
148
src/components/resource-launcher/LauncherSettingsMenu.tsx
Normal file
148
src/components/resource-launcher/LauncherSettingsMenu.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Label } from "@app/components/ui/label";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import type { LauncherViewConfig } from "@server/routers/launcher/types";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Settings } from "lucide-react";
|
||||
|
||||
type LauncherSettingsMenuProps = {
|
||||
config: LauncherViewConfig;
|
||||
isDefaultView: boolean;
|
||||
onConfigChange: (patch: Partial<LauncherViewConfig>) => void;
|
||||
onDeleteView: () => void;
|
||||
};
|
||||
|
||||
export function LauncherSettingsMenu({
|
||||
config,
|
||||
isDefaultView,
|
||||
onConfigChange,
|
||||
onDeleteView
|
||||
}: LauncherSettingsMenuProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="shrink-0">
|
||||
<Settings className="size-4" />
|
||||
<span className="sr-only">
|
||||
{t("resourceLauncherSettings")}
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-72">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-semibold">
|
||||
{t("resourceLauncherGroupBy")}
|
||||
</p>
|
||||
<Select
|
||||
value={config.groupBy}
|
||||
onValueChange={(value) =>
|
||||
onConfigChange({
|
||||
groupBy:
|
||||
value as LauncherViewConfig["groupBy"]
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="site">
|
||||
{t("resourceLauncherGroupBySite")}
|
||||
</SelectItem>
|
||||
<SelectItem value="label">
|
||||
{t("resourceLauncherGroupByLabel")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-semibold">
|
||||
{t("resourceLauncherLayout")}
|
||||
</p>
|
||||
<Select
|
||||
value={config.layout}
|
||||
onValueChange={(value) =>
|
||||
onConfigChange({
|
||||
layout: value as LauncherViewConfig["layout"]
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="grid">
|
||||
{t("resourceLauncherLayoutGrid")}
|
||||
</SelectItem>
|
||||
<SelectItem value="list">
|
||||
{t("resourceLauncherLayoutList")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label
|
||||
htmlFor="show-labels"
|
||||
className="text-sm font-semibold"
|
||||
>
|
||||
{t("resourceLauncherShowLabels")}
|
||||
</Label>
|
||||
<Switch
|
||||
id="show-labels"
|
||||
checked={config.showLabels}
|
||||
onCheckedChange={(checked) =>
|
||||
onConfigChange({ showLabels: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label
|
||||
htmlFor="show-site-tags"
|
||||
className="text-sm font-semibold"
|
||||
>
|
||||
{t("resourceLauncherShowSiteTags")}
|
||||
</Label>
|
||||
<Switch
|
||||
id="show-site-tags"
|
||||
checked={config.showSiteTags}
|
||||
onCheckedChange={(checked) =>
|
||||
onConfigChange({ showSiteTags: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isDefaultView ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full rounded-xl"
|
||||
onClick={onDeleteView}
|
||||
>
|
||||
{t("resourceLauncherDeleteView")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
38
src/components/resource-launcher/LauncherSortButton.tsx
Normal file
38
src/components/resource-launcher/LauncherSortButton.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ArrowDown01, ArrowUp10 } from "lucide-react";
|
||||
|
||||
type LauncherSortButtonProps = {
|
||||
order: "asc" | "desc";
|
||||
onToggle: () => void;
|
||||
};
|
||||
|
||||
export function LauncherSortButton({
|
||||
order,
|
||||
onToggle
|
||||
}: LauncherSortButtonProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
onClick={onToggle}
|
||||
title={
|
||||
order === "asc"
|
||||
? t("resourceLauncherSortAscending")
|
||||
: t("resourceLauncherSortDescending")
|
||||
}
|
||||
>
|
||||
{order === "asc" ? (
|
||||
<ArrowDown01 className="size-4" />
|
||||
) : (
|
||||
<ArrowUp10 className="size-4" />
|
||||
)}
|
||||
<span className="sr-only">{t("resourceLauncherSort")}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
121
src/components/resource-launcher/LauncherViewTabs.tsx
Normal file
121
src/components/resource-launcher/LauncherViewTabs.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { cn } from "@app/lib/cn";
|
||||
|
||||
type LauncherViewTabsProps = {
|
||||
activeViewId: number | "default";
|
||||
savedViews: Array<{ viewId: number; name: string }>;
|
||||
onSelectView: (viewId: number | "default") => void;
|
||||
};
|
||||
|
||||
export function LauncherViewTabs({
|
||||
activeViewId,
|
||||
savedViews,
|
||||
onSelectView
|
||||
}: LauncherViewTabsProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
const viewOptions: Array<{
|
||||
value: number | "default";
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "default", label: t("resourceLauncherDefaultView") },
|
||||
...savedViews.map((view) => ({
|
||||
value: view.viewId,
|
||||
label: view.name
|
||||
}))
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 overflow-x-auto max-w-full shrink min-w-0">
|
||||
{viewOptions.map((option) => {
|
||||
const isSelected = activeViewId === option.value;
|
||||
return (
|
||||
<Button
|
||||
key={option.value}
|
||||
type="button"
|
||||
variant={
|
||||
isSelected
|
||||
? "squareOutlinePrimary"
|
||||
: "squareOutline"
|
||||
}
|
||||
className={cn(
|
||||
"shrink-0 min-w-30 shadow-none",
|
||||
isSelected && "bg-primary/10"
|
||||
)}
|
||||
onClick={() => onSelectView(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type LauncherSaveViewMenuProps = {
|
||||
isDefaultView: boolean;
|
||||
isAdmin: boolean;
|
||||
isOrgWideView: boolean;
|
||||
hasUnsavedChanges: boolean;
|
||||
onSaveToCurrent: () => void;
|
||||
onSaveAsNew: () => void;
|
||||
onSaveForEveryone: () => void;
|
||||
onMakePersonal: () => void;
|
||||
};
|
||||
|
||||
export function LauncherSaveViewMenu({
|
||||
isDefaultView,
|
||||
isAdmin,
|
||||
isOrgWideView,
|
||||
hasUnsavedChanges,
|
||||
onSaveToCurrent,
|
||||
onSaveAsNew,
|
||||
onSaveForEveryone,
|
||||
onMakePersonal
|
||||
}: LauncherSaveViewMenuProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="shrink-0">
|
||||
{hasUnsavedChanges ? (
|
||||
<span className="size-2 rounded-full bg-primary mr-2" />
|
||||
) : null}
|
||||
{t("resourceLauncherSaveView")}
|
||||
<ChevronDown className="ml-2 size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{!isDefaultView ? (
|
||||
<DropdownMenuItem onSelect={onSaveToCurrent}>
|
||||
{t("resourceLauncherSaveToCurrentView")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem onSelect={onSaveAsNew}>
|
||||
{t("resourceLauncherSaveAsNewView")}
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && !isDefaultView && !isOrgWideView ? (
|
||||
<DropdownMenuItem onSelect={onSaveForEveryone}>
|
||||
{t("resourceLauncherSaveForEveryone")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{isAdmin && !isDefaultView && isOrgWideView ? (
|
||||
<DropdownMenuItem onSelect={onMakePersonal}>
|
||||
{t("resourceLauncherMakePersonal")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
532
src/components/resource-launcher/ResourceLauncher.tsx
Normal file
532
src/components/resource-launcher/ResourceLauncher.tsx
Normal file
@@ -0,0 +1,532 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { CheckboxWithLabel } from "@app/components/ui/checkbox";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { Label } from "@app/components/ui/label";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import {
|
||||
readLauncherLastView,
|
||||
writeLauncherLastView,
|
||||
type LauncherActiveViewId
|
||||
} from "@app/lib/launcherLocalStorage";
|
||||
import {
|
||||
buildLauncherPath,
|
||||
getLauncherUrlBaseConfig,
|
||||
isLauncherConfigEqual,
|
||||
resolveLauncherStateFromUrl,
|
||||
serializeLauncherUrlState
|
||||
} from "@app/lib/launcherUrlState";
|
||||
import { launcherQueries } from "@app/lib/queries";
|
||||
import { useToast } from "@app/hooks/useToast";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import {
|
||||
defaultLauncherViewConfig,
|
||||
type LauncherViewConfig,
|
||||
type LauncherViewRecord
|
||||
} from "@server/routers/launcher/types";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Search } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import type { Selectedsite } from "@app/components/site-selector";
|
||||
import type { SelectedLabel } from "@app/components/labels-selector";
|
||||
import { LauncherFilterPopover } from "./LauncherFilterPopover";
|
||||
import { LauncherGroupList } from "./LauncherGroupList";
|
||||
import { LauncherSettingsMenu } from "./LauncherSettingsMenu";
|
||||
import { LauncherSortButton } from "./LauncherSortButton";
|
||||
import { LauncherSaveViewMenu, LauncherViewTabs } from "./LauncherViewTabs";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
|
||||
type ResourceLauncherProps = {
|
||||
orgId: string;
|
||||
isAdmin: boolean;
|
||||
};
|
||||
|
||||
export default function ResourceLauncher({
|
||||
orgId,
|
||||
isAdmin
|
||||
}: ResourceLauncherProps) {
|
||||
const t = useTranslations();
|
||||
const { toast } = useToast();
|
||||
const { env } = useEnvContext();
|
||||
const queryClient = useQueryClient();
|
||||
const api = createApiClient({ env });
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [activeViewId, setActiveViewId] =
|
||||
useState<LauncherActiveViewId>("default");
|
||||
const hasRestoredLastView = useRef(false);
|
||||
const isApplyingUrlRef = useRef(false);
|
||||
|
||||
const [config, setConfig] = useState<LauncherViewConfig>(
|
||||
defaultLauncherViewConfig
|
||||
);
|
||||
const [savedConfig, setSavedConfig] = useState<LauncherViewConfig>(
|
||||
defaultLauncherViewConfig
|
||||
);
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
||||
const [newViewName, setNewViewName] = useState("");
|
||||
const [saveOrgWide, setSaveOrgWide] = useState(false);
|
||||
|
||||
const configRef = useRef(config);
|
||||
configRef.current = config;
|
||||
const searchInputRef = useRef(searchInput);
|
||||
searchInputRef.current = searchInput;
|
||||
const activeViewIdRef = useRef(activeViewId);
|
||||
activeViewIdRef.current = activeViewId;
|
||||
|
||||
const { data: views = [], isLoading: viewsLoading } = useQuery(
|
||||
launcherQueries.views(orgId)
|
||||
);
|
||||
|
||||
const syncUrl = useCallback(
|
||||
(viewId: LauncherActiveViewId, nextConfig: LauncherViewConfig) => {
|
||||
if (isApplyingUrlRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = serializeLauncherUrlState({
|
||||
viewId,
|
||||
config: nextConfig
|
||||
});
|
||||
const path = buildLauncherPath(orgId, params);
|
||||
router.replace(path, { scroll: false });
|
||||
},
|
||||
[orgId, router]
|
||||
);
|
||||
|
||||
const debouncedSyncSearch = useDebouncedCallback(
|
||||
(viewId: LauncherActiveViewId, query: string) => {
|
||||
const nextConfig = { ...configRef.current, query };
|
||||
setSearchQuery(query);
|
||||
syncUrl(viewId, nextConfig);
|
||||
},
|
||||
300
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewsLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
let fallbackViewId: LauncherActiveViewId | null = null;
|
||||
if (!hasRestoredLastView.current) {
|
||||
hasRestoredLastView.current = true;
|
||||
fallbackViewId = readLauncherLastView(orgId);
|
||||
}
|
||||
|
||||
isApplyingUrlRef.current = true;
|
||||
const resolved = resolveLauncherStateFromUrl(
|
||||
new URLSearchParams(searchParams),
|
||||
views,
|
||||
fallbackViewId
|
||||
);
|
||||
|
||||
setActiveViewId(resolved.activeViewId);
|
||||
setConfig(resolved.config);
|
||||
setSavedConfig(resolved.savedConfig);
|
||||
setSearchInput(resolved.config.query);
|
||||
setSearchQuery(resolved.config.query);
|
||||
isApplyingUrlRef.current = false;
|
||||
}, [orgId, searchParams, views, viewsLoading]);
|
||||
|
||||
const selectView = useCallback(
|
||||
(viewId: LauncherActiveViewId) => {
|
||||
writeLauncherLastView(orgId, viewId);
|
||||
const baseConfig = getLauncherUrlBaseConfig(viewId, views);
|
||||
syncUrl(viewId, baseConfig);
|
||||
},
|
||||
[orgId, syncUrl, views]
|
||||
);
|
||||
|
||||
const activeSavedView = useMemo(
|
||||
() =>
|
||||
activeViewId === "default"
|
||||
? null
|
||||
: views.find((view) => view.viewId === activeViewId),
|
||||
[activeViewId, views]
|
||||
);
|
||||
|
||||
const isDefaultView = activeViewId === "default";
|
||||
const isOrgWideView = Boolean(activeSavedView?.isOrgWide);
|
||||
const hasUnsavedChanges = !isLauncherConfigEqual(config, savedConfig);
|
||||
|
||||
const selectedSites: Selectedsite[] = useMemo(
|
||||
() =>
|
||||
config.siteIds.map((siteId) => ({
|
||||
siteId,
|
||||
name: String(siteId),
|
||||
type: "newt"
|
||||
})),
|
||||
[config.siteIds]
|
||||
);
|
||||
|
||||
const selectedLabels: SelectedLabel[] = useMemo(
|
||||
() =>
|
||||
config.labelIds.map((labelId) => ({
|
||||
labelId,
|
||||
name: String(labelId),
|
||||
color: "#a1a1aa"
|
||||
})),
|
||||
[config.labelIds]
|
||||
);
|
||||
|
||||
const invalidateLauncher = () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["ORG", orgId, "LAUNCHER"]
|
||||
});
|
||||
};
|
||||
|
||||
const createViewMutation = useMutation({
|
||||
mutationFn: async (payload: {
|
||||
name: string;
|
||||
config: LauncherViewConfig;
|
||||
orgWide: boolean;
|
||||
}) => {
|
||||
const res = await api.post(`/org/${orgId}/launcher/views`, payload);
|
||||
return res.data.data as LauncherViewRecord;
|
||||
},
|
||||
onSuccess: (view) => {
|
||||
invalidateLauncher();
|
||||
writeLauncherLastView(orgId, view.viewId);
|
||||
|
||||
isApplyingUrlRef.current = true;
|
||||
setActiveViewId(view.viewId);
|
||||
setConfig(view.config);
|
||||
setSavedConfig(view.config);
|
||||
setSearchInput(view.config.query);
|
||||
setSearchQuery(view.config.query);
|
||||
isApplyingUrlRef.current = false;
|
||||
|
||||
const params = serializeLauncherUrlState({
|
||||
viewId: view.viewId,
|
||||
config: view.config
|
||||
});
|
||||
router.replace(buildLauncherPath(orgId, params), { scroll: false });
|
||||
|
||||
setSaveDialogOpen(false);
|
||||
setNewViewName("");
|
||||
toast({
|
||||
title: t("resourceLauncherViewSaved"),
|
||||
description: t("resourceLauncherViewSavedDescription")
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("resourceLauncherViewSaveFailed"),
|
||||
description: formatAxiosError(
|
||||
error,
|
||||
t("resourceLauncherViewSaveFailedDescription")
|
||||
)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const updateViewMutation = useMutation({
|
||||
mutationFn: async (payload: {
|
||||
viewId: number;
|
||||
name?: string;
|
||||
config?: LauncherViewConfig;
|
||||
orgWide?: boolean;
|
||||
}) => {
|
||||
const { viewId, ...body } = payload;
|
||||
const res = await api.put(
|
||||
`/org/${orgId}/launcher/views/${viewId}`,
|
||||
body
|
||||
);
|
||||
return res.data.data as LauncherViewRecord;
|
||||
},
|
||||
onSuccess: (view) => {
|
||||
invalidateLauncher();
|
||||
|
||||
isApplyingUrlRef.current = true;
|
||||
setActiveViewId(view.viewId);
|
||||
setConfig(view.config);
|
||||
setSavedConfig(view.config);
|
||||
setSearchInput(view.config.query);
|
||||
setSearchQuery(view.config.query);
|
||||
isApplyingUrlRef.current = false;
|
||||
|
||||
const params = serializeLauncherUrlState({
|
||||
viewId: view.viewId,
|
||||
config: view.config
|
||||
});
|
||||
router.replace(buildLauncherPath(orgId, params), { scroll: false });
|
||||
|
||||
toast({
|
||||
title: t("resourceLauncherViewSaved"),
|
||||
description: t("resourceLauncherViewSavedDescription")
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("resourceLauncherViewSaveFailed"),
|
||||
description: formatAxiosError(
|
||||
error,
|
||||
t("resourceLauncherViewSaveFailedDescription")
|
||||
)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const deleteViewMutation = useMutation({
|
||||
mutationFn: async (viewId: number) => {
|
||||
await api.delete(`/org/${orgId}/launcher/views/${viewId}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
invalidateLauncher();
|
||||
selectView("default");
|
||||
toast({
|
||||
title: t("resourceLauncherViewDeleted"),
|
||||
description: t("resourceLauncherViewDeletedDescription")
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("resourceLauncherViewDeleteFailed"),
|
||||
description: formatAxiosError(
|
||||
error,
|
||||
t("resourceLauncherViewDeleteFailedDescription")
|
||||
)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const applyConfigPatch = useCallback(
|
||||
(patch: Partial<LauncherViewConfig>) => {
|
||||
const nextConfig = {
|
||||
...configRef.current,
|
||||
...patch,
|
||||
query: searchInputRef.current
|
||||
};
|
||||
syncUrl(activeViewIdRef.current, nextConfig);
|
||||
},
|
||||
[syncUrl]
|
||||
);
|
||||
|
||||
const handleSaveToCurrent = () => {
|
||||
if (isDefaultView) {
|
||||
return;
|
||||
}
|
||||
updateViewMutation.mutate({
|
||||
viewId: activeViewId,
|
||||
config
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveAsNew = () => {
|
||||
setSaveOrgWide(false);
|
||||
setNewViewName("");
|
||||
setSaveDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveForEveryone = () => {
|
||||
if (isDefaultView) {
|
||||
return;
|
||||
}
|
||||
updateViewMutation.mutate({
|
||||
viewId: activeViewId,
|
||||
orgWide: true
|
||||
});
|
||||
};
|
||||
|
||||
const handleMakePersonal = () => {
|
||||
if (isDefaultView) {
|
||||
return;
|
||||
}
|
||||
updateViewMutation.mutate({
|
||||
viewId: activeViewId,
|
||||
orgWide: false
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateView = () => {
|
||||
if (!newViewName.trim()) {
|
||||
return;
|
||||
}
|
||||
createViewMutation.mutate({
|
||||
name: newViewName.trim(),
|
||||
config,
|
||||
orgWide: saveOrgWide && isAdmin
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<SettingsSectionTitle
|
||||
title={t("resourceLauncherTitle")}
|
||||
description={t("resourceLauncherDescription")}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-3 mb-6">
|
||||
<div className="flex flex-col xl:flex-row xl:items-center gap-3 justify-between">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 min-w-0 flex-1">
|
||||
<div className="relative w-full sm:max-w-sm shrink-0">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchInput}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
setSearchInput(value);
|
||||
debouncedSyncSearch(
|
||||
activeViewIdRef.current,
|
||||
value
|
||||
);
|
||||
}}
|
||||
placeholder={t(
|
||||
"resourceLauncherSearchPlaceholder"
|
||||
)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
{!viewsLoading ? (
|
||||
<LauncherViewTabs
|
||||
activeViewId={activeViewId}
|
||||
savedViews={views.map((view) => ({
|
||||
viewId: view.viewId,
|
||||
name: view.name
|
||||
}))}
|
||||
onSelectView={selectView}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 justify-end">
|
||||
<LauncherSaveViewMenu
|
||||
isDefaultView={isDefaultView}
|
||||
isAdmin={isAdmin}
|
||||
isOrgWideView={isOrgWideView}
|
||||
hasUnsavedChanges={hasUnsavedChanges}
|
||||
onSaveToCurrent={handleSaveToCurrent}
|
||||
onSaveAsNew={handleSaveAsNew}
|
||||
onSaveForEveryone={handleSaveForEveryone}
|
||||
onMakePersonal={handleMakePersonal}
|
||||
/>
|
||||
<LauncherFilterPopover
|
||||
orgId={orgId}
|
||||
selectedSites={selectedSites}
|
||||
selectedLabels={selectedLabels}
|
||||
onSitesChange={(sites) =>
|
||||
applyConfigPatch({
|
||||
siteIds: sites.map((site) => site.siteId)
|
||||
})
|
||||
}
|
||||
onLabelsChange={(labels) =>
|
||||
applyConfigPatch({
|
||||
labelIds: labels.map(
|
||||
(label) => label.labelId
|
||||
)
|
||||
})
|
||||
}
|
||||
/>
|
||||
<LauncherSortButton
|
||||
order={config.order}
|
||||
onToggle={() =>
|
||||
applyConfigPatch({
|
||||
order:
|
||||
config.order === "asc" ? "desc" : "asc"
|
||||
})
|
||||
}
|
||||
/>
|
||||
<LauncherSettingsMenu
|
||||
config={config}
|
||||
isDefaultView={isDefaultView}
|
||||
onConfigChange={applyConfigPatch}
|
||||
onDeleteView={() => {
|
||||
if (!isDefaultView) {
|
||||
deleteViewMutation.mutate(activeViewId);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LauncherGroupList
|
||||
orgId={orgId}
|
||||
activeViewId={activeViewId}
|
||||
config={{ ...config, query: searchQuery }}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
|
||||
<Credenza open={saveDialogOpen} onOpenChange={setSaveDialogOpen}>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{t("resourceLauncherSaveAsNewView")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("resourceLauncherSaveAsNewViewDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-view-name">
|
||||
{t("resourceLauncherViewNameLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="new-view-name"
|
||||
value={newViewName}
|
||||
onChange={(event) =>
|
||||
setNewViewName(event.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{isAdmin ? (
|
||||
<div className="mt-4">
|
||||
<CheckboxWithLabel
|
||||
id="save-org-wide"
|
||||
aria-describedby="save-org-wide-desc"
|
||||
label={t("resourceLauncherSaveForEveryone")}
|
||||
checked={saveOrgWide}
|
||||
onCheckedChange={(checked) =>
|
||||
setSaveOrgWide(checked === true)
|
||||
}
|
||||
/>
|
||||
<p
|
||||
id="save-org-wide-desc"
|
||||
className="text-sm text-muted-foreground mt-2"
|
||||
>
|
||||
{t(
|
||||
"resourceLauncherSaveForEveryoneDescription"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSaveDialogOpen(false)}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateView}
|
||||
loading={createViewMutation.isPending}
|
||||
>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { useToast } from "@app/hooks/useToast";
|
||||
import { isSafeUrlForLink } from "@app/lib/launcherResourceAccess";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, type KeyboardEvent, type MouseEvent } from "react";
|
||||
|
||||
type LauncherResourceActionInput = {
|
||||
accessUrl?: string | null;
|
||||
accessCopyValue: string;
|
||||
};
|
||||
|
||||
export function useLauncherResourceAction({
|
||||
accessUrl,
|
||||
accessCopyValue
|
||||
}: LauncherResourceActionInput) {
|
||||
const { toast } = useToast();
|
||||
const t = useTranslations();
|
||||
|
||||
const href = accessUrl ?? undefined;
|
||||
const canLink = Boolean(href && isSafeUrlForLink(href));
|
||||
const isClickable = canLink || Boolean(accessCopyValue);
|
||||
|
||||
const handleAction = useCallback(() => {
|
||||
if (canLink && href) {
|
||||
window.open(href, "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessCopyValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
void navigator.clipboard.writeText(accessCopyValue).then(() => {
|
||||
toast({
|
||||
title: t("resourceLauncherCopiedToClipboard"),
|
||||
description: t("resourceLauncherCopiedAccessDescription"),
|
||||
duration: 2000
|
||||
});
|
||||
});
|
||||
}, [accessCopyValue, canLink, href, t, toast]);
|
||||
|
||||
return { handleAction, isClickable };
|
||||
}
|
||||
|
||||
export function isLauncherResourceInteractiveTarget(
|
||||
target: EventTarget | null
|
||||
): boolean {
|
||||
if (!(target instanceof Element)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Boolean(
|
||||
target.closest("a, button, [role='button'], input, textarea, select")
|
||||
);
|
||||
}
|
||||
|
||||
function handleLauncherResourceClick(
|
||||
event: MouseEvent,
|
||||
handleAction: () => void
|
||||
) {
|
||||
if (isLauncherResourceInteractiveTarget(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
handleAction();
|
||||
}
|
||||
|
||||
export function getLauncherResourceClickProps(
|
||||
handleAction: () => void,
|
||||
isClickable: boolean
|
||||
) {
|
||||
return {
|
||||
onClick: (event: MouseEvent) =>
|
||||
handleLauncherResourceClick(event, handleAction),
|
||||
className: isClickable ? "cursor-pointer" : undefined,
|
||||
role: isClickable ? ("button" as const) : undefined,
|
||||
tabIndex: isClickable ? 0 : undefined,
|
||||
onKeyDown: isClickable
|
||||
? (event: KeyboardEvent) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
handleAction();
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
};
|
||||
}
|
||||
98
src/lib/launcherLocalStorage.ts
Normal file
98
src/lib/launcherLocalStorage.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
export type LauncherActiveViewId = number | "default";
|
||||
|
||||
const LAST_VIEW_PREFIX = "pangolin:launcher:last-view:";
|
||||
const GROUP_OPEN_PREFIX = "pangolin:launcher:group-open:";
|
||||
|
||||
function lastViewKey(orgId: string) {
|
||||
return `${LAST_VIEW_PREFIX}${orgId}`;
|
||||
}
|
||||
|
||||
function groupOpenKey(
|
||||
orgId: string,
|
||||
viewId: LauncherActiveViewId,
|
||||
groupBy: "site" | "label"
|
||||
) {
|
||||
return `${GROUP_OPEN_PREFIX}${orgId}:${viewId}:${groupBy}`;
|
||||
}
|
||||
|
||||
function readJson<T>(key: string, fallback: T): T {
|
||||
if (typeof window === "undefined") {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
return raw ? (JSON.parse(raw) as T) : fallback;
|
||||
} catch (error) {
|
||||
console.warn(`Error reading localStorage key "${key}":`, error);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(key: string, value: unknown) {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch (error) {
|
||||
console.warn(`Error writing localStorage key "${key}":`, error);
|
||||
}
|
||||
}
|
||||
|
||||
export function readLauncherLastView(
|
||||
orgId: string
|
||||
): LauncherActiveViewId | null {
|
||||
const value = readJson<LauncherActiveViewId | null>(
|
||||
lastViewKey(orgId),
|
||||
null
|
||||
);
|
||||
if (value === "default" || typeof value === "number") {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function writeLauncherLastView(
|
||||
orgId: string,
|
||||
viewId: LauncherActiveViewId
|
||||
) {
|
||||
writeJson(lastViewKey(orgId), viewId);
|
||||
}
|
||||
|
||||
export function readLauncherGroupOpenState(
|
||||
orgId: string,
|
||||
viewId: LauncherActiveViewId,
|
||||
groupBy: "site" | "label"
|
||||
): Record<string, boolean> {
|
||||
return readJson<Record<string, boolean>>(
|
||||
groupOpenKey(orgId, viewId, groupBy),
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
export function readLauncherGroupOpen(
|
||||
orgId: string,
|
||||
viewId: LauncherActiveViewId,
|
||||
groupBy: "site" | "label",
|
||||
groupKey: string,
|
||||
defaultOpen: boolean
|
||||
): boolean {
|
||||
const state = readLauncherGroupOpenState(orgId, viewId, groupBy);
|
||||
return groupKey in state ? state[groupKey] : defaultOpen;
|
||||
}
|
||||
|
||||
export function writeLauncherGroupOpen(
|
||||
orgId: string,
|
||||
viewId: LauncherActiveViewId,
|
||||
groupBy: "site" | "label",
|
||||
groupKey: string,
|
||||
isOpen: boolean
|
||||
) {
|
||||
const state = readLauncherGroupOpenState(orgId, viewId, groupBy);
|
||||
writeJson(groupOpenKey(orgId, viewId, groupBy), {
|
||||
...state,
|
||||
[groupKey]: isOpen
|
||||
});
|
||||
}
|
||||
123
src/lib/launcherResourceAccess.ts
Normal file
123
src/lib/launcherResourceAccess.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
formatSiteResourceDestinationDisplay,
|
||||
type SiteResourceDestinationInput
|
||||
} from "./formatSiteResourceAccess";
|
||||
|
||||
export {
|
||||
formatSiteResourceDestinationDisplay,
|
||||
resolveHttpHttpsDisplayPort,
|
||||
type SiteResourceDestinationInput
|
||||
} from "./formatSiteResourceAccess";
|
||||
|
||||
export type PublicResourceAccessInput = {
|
||||
mode: string;
|
||||
fullDomain: string | null;
|
||||
ssl: boolean;
|
||||
proxyPort: number | null;
|
||||
wildcard: boolean;
|
||||
};
|
||||
|
||||
export type SiteResourceAccessInput = {
|
||||
mode: string;
|
||||
destination: string | null;
|
||||
destinationPort: number | null;
|
||||
scheme: "http" | "https" | null;
|
||||
ssl: boolean;
|
||||
fullDomain: string | null;
|
||||
alias: string | null;
|
||||
aliasAddress: string | null;
|
||||
};
|
||||
|
||||
export type LauncherAccessFields = {
|
||||
accessDisplay: string;
|
||||
accessCopyValue: string;
|
||||
accessUrl: string | null;
|
||||
};
|
||||
|
||||
export function formatPublicResourceAccess(
|
||||
resource: PublicResourceAccessInput
|
||||
): LauncherAccessFields {
|
||||
const browserModes = ["http", "ssh", "rdp", "vnc"];
|
||||
if (!browserModes.includes(resource.mode)) {
|
||||
const port = resource.proxyPort?.toString() ?? "";
|
||||
return {
|
||||
accessDisplay: port,
|
||||
accessCopyValue: port,
|
||||
accessUrl: null
|
||||
};
|
||||
}
|
||||
|
||||
if (!resource.fullDomain) {
|
||||
return {
|
||||
accessDisplay: "",
|
||||
accessCopyValue: "",
|
||||
accessUrl: null
|
||||
};
|
||||
}
|
||||
|
||||
const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
|
||||
return {
|
||||
accessDisplay: url,
|
||||
accessCopyValue: url,
|
||||
accessUrl: resource.wildcard ? null : url
|
||||
};
|
||||
}
|
||||
|
||||
export function formatSiteResourceAccess(
|
||||
resource: SiteResourceAccessInput
|
||||
): LauncherAccessFields {
|
||||
if (resource.alias) {
|
||||
return {
|
||||
accessDisplay: resource.alias,
|
||||
accessCopyValue: resource.alias,
|
||||
accessUrl: null
|
||||
};
|
||||
}
|
||||
|
||||
if (resource.mode === "http" && resource.fullDomain) {
|
||||
const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
|
||||
return {
|
||||
accessDisplay: url,
|
||||
accessCopyValue: url,
|
||||
accessUrl: url
|
||||
};
|
||||
}
|
||||
|
||||
const destination = formatSiteResourceDestinationDisplay({
|
||||
mode: resource.mode as SiteResourceDestinationInput["mode"],
|
||||
destination: resource.destination,
|
||||
destinationPort: resource.destinationPort,
|
||||
scheme: resource.scheme
|
||||
});
|
||||
|
||||
if (destination) {
|
||||
return {
|
||||
accessDisplay: destination,
|
||||
accessCopyValue: destination,
|
||||
accessUrl: resource.mode === "http" ? destination : null
|
||||
};
|
||||
}
|
||||
|
||||
if (resource.aliasAddress) {
|
||||
return {
|
||||
accessDisplay: resource.aliasAddress,
|
||||
accessCopyValue: resource.aliasAddress,
|
||||
accessUrl: null
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
accessDisplay: "",
|
||||
accessCopyValue: "",
|
||||
accessUrl: null
|
||||
};
|
||||
}
|
||||
|
||||
export function isSafeUrlForLink(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
292
src/lib/launcherUrlState.ts
Normal file
292
src/lib/launcherUrlState.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage";
|
||||
import {
|
||||
defaultLauncherViewConfig,
|
||||
parseIdListParam,
|
||||
type LauncherViewConfig,
|
||||
type LauncherViewRecord
|
||||
} from "@server/routers/launcher/types";
|
||||
import { z } from "zod";
|
||||
|
||||
const launcherUrlBooleanSchema = z
|
||||
.enum(["0", "1"])
|
||||
.transform((value) => value === "1");
|
||||
|
||||
export type LauncherUrlConfigOverrides = Partial<
|
||||
Pick<
|
||||
LauncherViewConfig,
|
||||
| "groupBy"
|
||||
| "layout"
|
||||
| "order"
|
||||
| "showLabels"
|
||||
| "showSiteTags"
|
||||
| "siteIds"
|
||||
| "labelIds"
|
||||
| "query"
|
||||
>
|
||||
>;
|
||||
|
||||
export type ParsedLauncherUrlState = {
|
||||
viewId: LauncherActiveViewId | null;
|
||||
configOverrides: LauncherUrlConfigOverrides;
|
||||
hasAnyLauncherParams: boolean;
|
||||
};
|
||||
|
||||
export type ResolvedLauncherState = {
|
||||
activeViewId: LauncherActiveViewId;
|
||||
config: LauncherViewConfig;
|
||||
savedConfig: LauncherViewConfig;
|
||||
};
|
||||
|
||||
const LAUNCHER_CONFIG_PARAM_KEYS = [
|
||||
"query",
|
||||
"groupBy",
|
||||
"layout",
|
||||
"order",
|
||||
"showLabels",
|
||||
"showSiteTags",
|
||||
"siteIds",
|
||||
"labelIds"
|
||||
] as const;
|
||||
|
||||
const LAUNCHER_URL_PARAM_KEYS = [
|
||||
"view",
|
||||
...LAUNCHER_CONFIG_PARAM_KEYS
|
||||
] as const;
|
||||
|
||||
export function hasLauncherConfigParams(searchParams: URLSearchParams) {
|
||||
return LAUNCHER_CONFIG_PARAM_KEYS.some((key) => searchParams.has(key));
|
||||
}
|
||||
|
||||
export function isLauncherConfigEqual(
|
||||
a: LauncherViewConfig,
|
||||
b: LauncherViewConfig
|
||||
) {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
export function getLauncherUrlBaseConfig(
|
||||
viewId: LauncherActiveViewId,
|
||||
views: LauncherViewRecord[]
|
||||
): LauncherViewConfig {
|
||||
if (viewId === "default") {
|
||||
return defaultLauncherViewConfig;
|
||||
}
|
||||
|
||||
const savedView = views.find((view) => view.viewId === viewId);
|
||||
return savedView?.config ?? defaultLauncherViewConfig;
|
||||
}
|
||||
|
||||
export function resolveLauncherConfig(
|
||||
baseConfig: LauncherViewConfig,
|
||||
overrides: LauncherUrlConfigOverrides
|
||||
): LauncherViewConfig {
|
||||
return {
|
||||
...baseConfig,
|
||||
...overrides,
|
||||
sortBy: "name"
|
||||
};
|
||||
}
|
||||
|
||||
function parseViewParam(value: string | null): LauncherActiveViewId | null {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value === "default") {
|
||||
return "default";
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return "default";
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseConfigOverrides(
|
||||
searchParams: URLSearchParams
|
||||
): LauncherUrlConfigOverrides {
|
||||
const overrides: LauncherUrlConfigOverrides = {};
|
||||
|
||||
const query = searchParams.get("query");
|
||||
if (query !== null) {
|
||||
overrides.query = query;
|
||||
}
|
||||
|
||||
const groupBy = searchParams.get("groupBy");
|
||||
if (groupBy === "site" || groupBy === "label") {
|
||||
overrides.groupBy = groupBy;
|
||||
}
|
||||
|
||||
const layout = searchParams.get("layout");
|
||||
if (layout === "grid" || layout === "list") {
|
||||
overrides.layout = layout;
|
||||
}
|
||||
|
||||
const order = searchParams.get("order");
|
||||
if (order === "asc" || order === "desc") {
|
||||
overrides.order = order;
|
||||
}
|
||||
|
||||
const showLabels = searchParams.get("showLabels");
|
||||
if (showLabels !== null) {
|
||||
const parsed = launcherUrlBooleanSchema.safeParse(showLabels);
|
||||
if (parsed.success) {
|
||||
overrides.showLabels = parsed.data;
|
||||
}
|
||||
}
|
||||
|
||||
const showSiteTags = searchParams.get("showSiteTags");
|
||||
if (showSiteTags !== null) {
|
||||
const parsed = launcherUrlBooleanSchema.safeParse(showSiteTags);
|
||||
if (parsed.success) {
|
||||
overrides.showSiteTags = parsed.data;
|
||||
}
|
||||
}
|
||||
|
||||
const siteIds = searchParams.get("siteIds");
|
||||
if (siteIds !== null) {
|
||||
overrides.siteIds = parseIdListParam(siteIds);
|
||||
}
|
||||
|
||||
const labelIds = searchParams.get("labelIds");
|
||||
if (labelIds !== null) {
|
||||
overrides.labelIds = parseIdListParam(labelIds);
|
||||
}
|
||||
|
||||
return overrides;
|
||||
}
|
||||
|
||||
export function parseLauncherUrlState(
|
||||
searchParams: URLSearchParams
|
||||
): ParsedLauncherUrlState {
|
||||
const hasAnyLauncherParams = LAUNCHER_URL_PARAM_KEYS.some((key) =>
|
||||
searchParams.has(key)
|
||||
);
|
||||
|
||||
return {
|
||||
viewId: parseViewParam(searchParams.get("view")),
|
||||
configOverrides: parseConfigOverrides(searchParams),
|
||||
hasAnyLauncherParams
|
||||
};
|
||||
}
|
||||
|
||||
function isValidActiveViewId(
|
||||
viewId: LauncherActiveViewId,
|
||||
views: LauncherViewRecord[]
|
||||
) {
|
||||
return viewId === "default" || views.some((view) => view.viewId === viewId);
|
||||
}
|
||||
|
||||
export function resolveLauncherStateFromUrl(
|
||||
searchParams: URLSearchParams,
|
||||
views: LauncherViewRecord[],
|
||||
fallbackViewId: LauncherActiveViewId | null
|
||||
): ResolvedLauncherState {
|
||||
const parsed = parseLauncherUrlState(searchParams);
|
||||
|
||||
let activeViewId: LauncherActiveViewId = "default";
|
||||
|
||||
if (parsed.viewId !== null) {
|
||||
activeViewId = isValidActiveViewId(parsed.viewId, views)
|
||||
? parsed.viewId
|
||||
: "default";
|
||||
} else if (!parsed.hasAnyLauncherParams && fallbackViewId !== null) {
|
||||
activeViewId = isValidActiveViewId(fallbackViewId, views)
|
||||
? fallbackViewId
|
||||
: "default";
|
||||
}
|
||||
|
||||
const savedConfig = getLauncherUrlBaseConfig(activeViewId, views);
|
||||
|
||||
let config: LauncherViewConfig;
|
||||
if (hasLauncherConfigParams(searchParams)) {
|
||||
config = resolveLauncherConfig(
|
||||
defaultLauncherViewConfig,
|
||||
parsed.configOverrides
|
||||
);
|
||||
} else if (activeViewId !== "default") {
|
||||
config = savedConfig;
|
||||
} else {
|
||||
config = defaultLauncherViewConfig;
|
||||
}
|
||||
|
||||
return {
|
||||
activeViewId,
|
||||
config,
|
||||
savedConfig
|
||||
};
|
||||
}
|
||||
|
||||
function idListsEqual(a: number[], b: number[]) {
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return a.every((value, index) => value === b[index]);
|
||||
}
|
||||
|
||||
export function serializeLauncherUrlState({
|
||||
viewId,
|
||||
config
|
||||
}: {
|
||||
viewId: LauncherActiveViewId;
|
||||
config: LauncherViewConfig;
|
||||
}): URLSearchParams {
|
||||
const baseConfig = defaultLauncherViewConfig;
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (viewId !== "default") {
|
||||
params.set("view", String(viewId));
|
||||
}
|
||||
|
||||
if (config.query !== baseConfig.query && config.query) {
|
||||
params.set("query", config.query);
|
||||
} else if (config.query !== baseConfig.query && !config.query) {
|
||||
params.set("query", "");
|
||||
}
|
||||
|
||||
if (config.groupBy !== baseConfig.groupBy) {
|
||||
params.set("groupBy", config.groupBy);
|
||||
}
|
||||
|
||||
if (config.layout !== baseConfig.layout) {
|
||||
params.set("layout", config.layout);
|
||||
}
|
||||
|
||||
if (config.order !== baseConfig.order) {
|
||||
params.set("order", config.order);
|
||||
}
|
||||
|
||||
if (config.showLabels !== baseConfig.showLabels) {
|
||||
params.set("showLabels", config.showLabels ? "1" : "0");
|
||||
}
|
||||
|
||||
if (config.showSiteTags !== baseConfig.showSiteTags) {
|
||||
params.set("showSiteTags", config.showSiteTags ? "1" : "0");
|
||||
}
|
||||
|
||||
if (!idListsEqual(config.siteIds, baseConfig.siteIds)) {
|
||||
if (config.siteIds.length > 0) {
|
||||
params.set("siteIds", config.siteIds.join(","));
|
||||
} else {
|
||||
params.set("siteIds", "");
|
||||
}
|
||||
}
|
||||
|
||||
if (!idListsEqual(config.labelIds, baseConfig.labelIds)) {
|
||||
if (config.labelIds.length > 0) {
|
||||
params.set("labelIds", config.labelIds.join(","));
|
||||
} else {
|
||||
params.set("labelIds", "");
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
export function buildLauncherPath(orgId: string, params: URLSearchParams) {
|
||||
const query = params.toString();
|
||||
return query ? `/${orgId}?${query}` : `/${orgId}`;
|
||||
}
|
||||
@@ -46,6 +46,13 @@ import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
|
||||
import { StatusHistoryResponse } from "@server/lib/statusHistory";
|
||||
import type { ListResourcePoliciesResponse } from "@server/routers/resource/types";
|
||||
import type { GetResourcePolicyResponse } from "@server/routers/policy";
|
||||
import type {
|
||||
ListLauncherGroupsResponse,
|
||||
ListLauncherResourcesResponse,
|
||||
ListLauncherViewsResponse,
|
||||
LauncherListQuery,
|
||||
LauncherViewConfig
|
||||
} from "@server/routers/launcher/types";
|
||||
|
||||
export type ProductUpdate = {
|
||||
link: string | null;
|
||||
@@ -1166,3 +1173,96 @@ export const domainQueries = {
|
||||
refetchInterval: durationToMs(10, "seconds")
|
||||
})
|
||||
};
|
||||
|
||||
export type LauncherQueryFilters = {
|
||||
query?: string;
|
||||
groupBy?: LauncherListQuery["groupBy"];
|
||||
groupKey?: string;
|
||||
siteIds?: number[];
|
||||
labelIds?: number[];
|
||||
sort_by?: LauncherListQuery["sort_by"];
|
||||
order?: LauncherListQuery["order"];
|
||||
pageSize?: number;
|
||||
};
|
||||
|
||||
function launcherSearchParams(filters: LauncherQueryFilters, page: number) {
|
||||
const sp = new URLSearchParams();
|
||||
sp.set("page", String(page));
|
||||
sp.set("pageSize", String(filters.pageSize ?? 20));
|
||||
if (filters.query) {
|
||||
sp.set("query", filters.query);
|
||||
}
|
||||
if (filters.groupBy) {
|
||||
sp.set("groupBy", filters.groupBy);
|
||||
}
|
||||
if (filters.groupKey) {
|
||||
sp.set("groupKey", filters.groupKey);
|
||||
}
|
||||
if (filters.siteIds?.length) {
|
||||
sp.set("siteIds", filters.siteIds.join(","));
|
||||
}
|
||||
if (filters.labelIds?.length) {
|
||||
sp.set("labelIds", filters.labelIds.join(","));
|
||||
}
|
||||
if (filters.sort_by) {
|
||||
sp.set("sort_by", filters.sort_by);
|
||||
}
|
||||
if (filters.order) {
|
||||
sp.set("order", filters.order);
|
||||
}
|
||||
return sp;
|
||||
}
|
||||
|
||||
export const launcherQueries = {
|
||||
views: (orgId: string) =>
|
||||
queryOptions({
|
||||
queryKey: ["ORG", orgId, "LAUNCHER", "VIEWS"] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<ListLauncherViewsResponse>
|
||||
>(`/org/${orgId}/launcher/views`, { signal });
|
||||
return res.data.data.views;
|
||||
}
|
||||
}),
|
||||
groups: (orgId: string, filters: LauncherQueryFilters) =>
|
||||
infiniteQueryOptions({
|
||||
queryKey: ["ORG", orgId, "LAUNCHER", "GROUPS", filters] as const,
|
||||
queryFn: async ({ pageParam = 1, signal, meta }) => {
|
||||
const sp = launcherSearchParams(filters, pageParam);
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<ListLauncherGroupsResponse>
|
||||
>(`/org/${orgId}/launcher/groups?${sp.toString()}`, { signal });
|
||||
return res.data.data;
|
||||
},
|
||||
initialPageParam: 1,
|
||||
placeholderData: keepPreviousData,
|
||||
getNextPageParam: (lastPage) => {
|
||||
const { page, pageSize, total } = lastPage.pagination;
|
||||
const nextPage = page + 1;
|
||||
return page * pageSize < total ? nextPage : undefined;
|
||||
}
|
||||
}),
|
||||
resources: (
|
||||
orgId: string,
|
||||
filters: LauncherQueryFilters & { groupKey: string }
|
||||
) =>
|
||||
infiniteQueryOptions({
|
||||
queryKey: ["ORG", orgId, "LAUNCHER", "RESOURCES", filters] as const,
|
||||
queryFn: async ({ pageParam = 1, signal, meta }) => {
|
||||
const sp = launcherSearchParams(filters, pageParam);
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<ListLauncherResourcesResponse>
|
||||
>(`/org/${orgId}/launcher/resources?${sp.toString()}`, {
|
||||
signal
|
||||
});
|
||||
return res.data.data;
|
||||
},
|
||||
initialPageParam: 1,
|
||||
placeholderData: keepPreviousData,
|
||||
getNextPageParam: (lastPage) => {
|
||||
const { page, pageSize, total } = lastPage.pagination;
|
||||
const nextPage = page + 1;
|
||||
return page * pageSize < total ? nextPage : undefined;
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user