mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
feat(den): add teams and skill hub management (#1289)
* feat(den): add teams and skill hub management * feat(den-web): add UnderlineTabs component and apply to members + skill hubs screens * feat(den-web): add DashboardPageTemplate and apply to all 6 dashboard pages * feat(den-web): add DenButton component and apply across all dashboard pages * feat(den-web): add DenInput component and apply across all dashboard inputs * feat(den-web): UI polish pass — shared component system and skill hub redesign - Add UnderlineTabs, DashboardPageTemplate, DenButton, DenInput, DenTextarea shared components - Apply DashboardPageTemplate with PaperMeshGradient headers to all 6 dashboard pages - Apply DenButton (primary/secondary/destructive + loading/disabled) across all dashboard pages - Apply DenInput and DenTextarea replacing all raw inputs and textareas - Redesign skill hub list cards: PaperMeshGradient seeded by hub ID, clean layout - Redesign skill list cards: PaperMeshGradient seeded by skill ID, matching hub card design - Rewrite skill hub detail page: lighter type scale, moved last-updated inline, clean sidebar - Rewrite skill detail page: gradient header, visibility pill inline with title, removed sidebar - Rewrite skill editor: remove category field (not persisted), clean form layout - Clean up all 4 member tables: tighter rows, items-center alignment, lighter type - Fix ActionButton icon stacking bug (Tailwind Preflight svg display:block via icon prop) - Move member tab toolbar buttons inline with description text per tab - Add destructive button variant; fix button disabled/loading states - Clean up manage-members, billing, templates, background-agents screen designs --------- Co-authored-by: src-opn <src-opn@users.noreply.github.com> Co-authored-by: OmarMcAdam <gh@mcadam.io>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { and, asc, eq } from "@openwork-ee/den-db/drizzle"
|
||||
import { and, asc, eq, inArray } from "@openwork-ee/den-db/drizzle"
|
||||
import {
|
||||
AuthSessionTable,
|
||||
AuthUserTable,
|
||||
@@ -98,6 +98,13 @@ export type OrganizationContext = {
|
||||
createdAt: Date | null
|
||||
updatedAt: Date | null
|
||||
}>
|
||||
teams: Array<{
|
||||
id: typeof TeamTable.$inferSelect.id
|
||||
name: string
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
memberIds: MemberId[]
|
||||
}>
|
||||
}
|
||||
|
||||
export type MemberTeamSummary = {
|
||||
@@ -611,6 +618,8 @@ export async function getOrganizationContextForUser(input: {
|
||||
.where(eq(OrganizationRoleTable.organizationId, organization.id))
|
||||
.orderBy(asc(OrganizationRoleTable.createdAt))
|
||||
|
||||
const teams = await listOrganizationTeams(organization.id)
|
||||
|
||||
const builtInDynamicRoleNames = new Set(Object.keys(denDefaultDynamicOrganizationRoles))
|
||||
|
||||
return {
|
||||
@@ -655,9 +664,47 @@ export async function getOrganizationContextForUser(input: {
|
||||
updatedAt: role.updatedAt,
|
||||
})),
|
||||
],
|
||||
teams,
|
||||
} satisfies OrganizationContext
|
||||
}
|
||||
|
||||
async function listOrganizationTeams(organizationId: OrgId) {
|
||||
const teams = await db
|
||||
.select({
|
||||
id: TeamTable.id,
|
||||
name: TeamTable.name,
|
||||
createdAt: TeamTable.createdAt,
|
||||
updatedAt: TeamTable.updatedAt,
|
||||
})
|
||||
.from(TeamTable)
|
||||
.where(eq(TeamTable.organizationId, organizationId))
|
||||
.orderBy(asc(TeamTable.createdAt))
|
||||
|
||||
if (teams.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const memberships = await db
|
||||
.select({
|
||||
teamId: TeamMemberTable.teamId,
|
||||
orgMembershipId: TeamMemberTable.orgMembershipId,
|
||||
})
|
||||
.from(TeamMemberTable)
|
||||
.where(inArray(TeamMemberTable.teamId, teams.map((team) => team.id)))
|
||||
|
||||
const memberIdsByTeamId = new Map<typeof TeamTable.$inferSelect.id, MemberId[]>()
|
||||
for (const membership of memberships) {
|
||||
const existing = memberIdsByTeamId.get(membership.teamId) ?? []
|
||||
existing.push(membership.orgMembershipId)
|
||||
memberIdsByTeamId.set(membership.teamId, existing)
|
||||
}
|
||||
|
||||
return teams.map((team) => ({
|
||||
...team,
|
||||
memberIds: memberIdsByTeamId.get(team.id) ?? [],
|
||||
}))
|
||||
}
|
||||
|
||||
export async function listTeamsForMember(input: {
|
||||
organizationId: OrgId
|
||||
memberId: MemberRow["id"]
|
||||
|
||||
@@ -5,6 +5,7 @@ import { registerOrgInvitationRoutes } from "./invitations.js"
|
||||
import { registerOrgMemberRoutes } from "./members.js"
|
||||
import { registerOrgRoleRoutes } from "./roles.js"
|
||||
import { registerOrgSkillRoutes } from "./skills.js"
|
||||
import { registerOrgTeamRoutes } from "./teams.js"
|
||||
import { registerOrgTemplateRoutes } from "./templates.js"
|
||||
|
||||
export function registerOrgRoutes<T extends { Variables: OrgRouteVariables }>(app: Hono<T>) {
|
||||
@@ -13,5 +14,6 @@ export function registerOrgRoutes<T extends { Variables: OrgRouteVariables }>(ap
|
||||
registerOrgMemberRoutes(app)
|
||||
registerOrgRoleRoutes(app)
|
||||
registerOrgSkillRoutes(app)
|
||||
registerOrgTeamRoutes(app)
|
||||
registerOrgTemplateRoutes(app)
|
||||
}
|
||||
|
||||
@@ -104,6 +104,30 @@ export function ensureInviteManager(c: { get: (key: "organizationContext") => Or
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureTeamManager(c: { get: (key: "organizationContext") => OrgRouteVariables["organizationContext"] }) {
|
||||
const payload = c.get("organizationContext")
|
||||
if (!payload) {
|
||||
return {
|
||||
ok: false as const,
|
||||
response: {
|
||||
error: "organization_not_found",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.currentMember.isOwner || memberHasRole(payload.currentMember.role, "admin")) {
|
||||
return { ok: true as const }
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false as const,
|
||||
response: {
|
||||
error: "forbidden",
|
||||
message: "Only organization owners and admins can manage teams.",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createInvitationId() {
|
||||
return createDenTypeId("invitation")
|
||||
}
|
||||
|
||||
@@ -28,6 +28,19 @@ const createSkillSchema = z.object({
|
||||
shared: z.enum(["org", "public"]).nullable().optional(),
|
||||
})
|
||||
|
||||
const updateSkillSchema = z.object({
|
||||
skillText: z.string().trim().min(1).optional(),
|
||||
shared: z.enum(["org", "public"]).nullable().optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
if (value.skillText === undefined && value.shared === undefined) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["skillText"],
|
||||
message: "Provide at least one field to update.",
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const createSkillHubSchema = z.object({
|
||||
name: z.string().trim().min(1).max(255),
|
||||
description: z.string().trim().max(65535).nullish().transform((value) => value || null),
|
||||
@@ -310,6 +323,68 @@ export function registerOrgSkillRoutes<T extends { Variables: OrgRouteVariables
|
||||
},
|
||||
)
|
||||
|
||||
app.patch(
|
||||
"/v1/orgs/:orgId/skills/:skillId",
|
||||
requireUserMiddleware,
|
||||
paramValidator(orgSkillParamsSchema),
|
||||
resolveOrganizationContextMiddleware,
|
||||
jsonValidator(updateSkillSchema),
|
||||
async (c) => {
|
||||
const payload = c.get("organizationContext")
|
||||
const params = c.req.valid("param")
|
||||
const input = c.req.valid("json")
|
||||
|
||||
let skillId: SkillId
|
||||
try {
|
||||
skillId = parseSkillId(params.skillId)
|
||||
} catch {
|
||||
return c.json({ error: "skill_not_found" }, 404)
|
||||
}
|
||||
|
||||
const skillRows = await db
|
||||
.select()
|
||||
.from(SkillTable)
|
||||
.where(and(eq(SkillTable.id, skillId), eq(SkillTable.organizationId, payload.organization.id)))
|
||||
.limit(1)
|
||||
|
||||
const skill = skillRows[0]
|
||||
if (!skill) {
|
||||
return c.json({ error: "skill_not_found" }, 404)
|
||||
}
|
||||
|
||||
if (!canManageSkill(payload, skill)) {
|
||||
return c.json({ error: "forbidden", message: "Only the skill creator or an org admin can update skills." }, 403)
|
||||
}
|
||||
|
||||
const nextSkillText = input.skillText ?? skill.skillText
|
||||
const metadata = parseSkillMetadata(nextSkillText)
|
||||
const updatedAt = new Date()
|
||||
const nextShared = input.shared === undefined ? skill.shared : input.shared
|
||||
|
||||
await db
|
||||
.update(SkillTable)
|
||||
.set({
|
||||
title: metadata.title,
|
||||
description: metadata.description,
|
||||
skillText: nextSkillText,
|
||||
shared: nextShared,
|
||||
updatedAt,
|
||||
})
|
||||
.where(eq(SkillTable.id, skill.id))
|
||||
|
||||
return c.json({
|
||||
skill: {
|
||||
...skill,
|
||||
title: metadata.title,
|
||||
description: metadata.description,
|
||||
skillText: nextSkillText,
|
||||
shared: nextShared,
|
||||
updatedAt,
|
||||
},
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
app.post(
|
||||
"/v1/orgs/:orgId/skill-hubs",
|
||||
requireUserMiddleware,
|
||||
|
||||
284
ee/apps/den-api/src/routes/org/teams.ts
Normal file
284
ee/apps/den-api/src/routes/org/teams.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import { and, eq } from "@openwork-ee/den-db/drizzle"
|
||||
import {
|
||||
MemberTable,
|
||||
SkillHubMemberTable,
|
||||
TeamMemberTable,
|
||||
TeamTable,
|
||||
} from "@openwork-ee/den-db/schema"
|
||||
import { createDenTypeId, normalizeDenTypeId } from "@openwork-ee/utils/typeid"
|
||||
import type { Hono } from "hono"
|
||||
import { z } from "zod"
|
||||
import { db } from "../../db.js"
|
||||
import {
|
||||
jsonValidator,
|
||||
paramValidator,
|
||||
requireUserMiddleware,
|
||||
resolveOrganizationContextMiddleware,
|
||||
} from "../../middleware/index.js"
|
||||
import type { OrgRouteVariables } from "./shared.js"
|
||||
import {
|
||||
ensureTeamManager,
|
||||
idParamSchema,
|
||||
orgIdParamSchema,
|
||||
} from "./shared.js"
|
||||
|
||||
const createTeamSchema = z.object({
|
||||
name: z.string().trim().min(1).max(255),
|
||||
memberIds: z.array(z.string().trim().min(1)).optional().default([]),
|
||||
})
|
||||
|
||||
const updateTeamSchema = z.object({
|
||||
name: z.string().trim().min(1).max(255).optional(),
|
||||
memberIds: z.array(z.string().trim().min(1)).optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
if (value.name === undefined && value.memberIds === undefined) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["name"],
|
||||
message: "Provide at least one field to update.",
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
type TeamId = typeof TeamTable.$inferSelect.id
|
||||
type MemberId = typeof MemberTable.$inferSelect.id
|
||||
|
||||
const orgTeamParamsSchema = orgIdParamSchema.extend(idParamSchema("teamId").shape)
|
||||
|
||||
function parseTeamId(value: string) {
|
||||
return normalizeDenTypeId("team", value)
|
||||
}
|
||||
|
||||
function parseMemberIds(memberIds: string[]) {
|
||||
return [...new Set(memberIds.map((value) => normalizeDenTypeId("member", value)))]
|
||||
}
|
||||
|
||||
async function ensureMembersBelongToOrganization(input: {
|
||||
organizationId: typeof TeamTable.$inferSelect.organizationId
|
||||
memberIds: MemberId[]
|
||||
}) {
|
||||
if (input.memberIds.length === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({ id: MemberTable.id })
|
||||
.from(MemberTable)
|
||||
.where(eq(MemberTable.organizationId, input.organizationId))
|
||||
|
||||
const memberIds = new Set(rows.map((row) => row.id))
|
||||
return input.memberIds.every((memberId) => memberIds.has(memberId))
|
||||
}
|
||||
|
||||
export function registerOrgTeamRoutes<T extends { Variables: OrgRouteVariables }>(app: Hono<T>) {
|
||||
app.post(
|
||||
"/v1/orgs/:orgId/teams",
|
||||
requireUserMiddleware,
|
||||
paramValidator(orgIdParamSchema),
|
||||
resolveOrganizationContextMiddleware,
|
||||
jsonValidator(createTeamSchema),
|
||||
async (c) => {
|
||||
const permission = ensureTeamManager(c)
|
||||
if (!permission.ok) {
|
||||
return c.json(permission.response, 403)
|
||||
}
|
||||
|
||||
const payload = c.get("organizationContext")
|
||||
const input = c.req.valid("json")
|
||||
|
||||
let memberIds: MemberId[]
|
||||
try {
|
||||
memberIds = parseMemberIds(input.memberIds)
|
||||
} catch {
|
||||
return c.json({ error: "member_not_found" }, 404)
|
||||
}
|
||||
|
||||
const membersBelongToOrg = await ensureMembersBelongToOrganization({
|
||||
organizationId: payload.organization.id,
|
||||
memberIds,
|
||||
})
|
||||
if (!membersBelongToOrg) {
|
||||
return c.json({ error: "member_not_found" }, 404)
|
||||
}
|
||||
|
||||
const existingTeam = await db
|
||||
.select({ id: TeamTable.id })
|
||||
.from(TeamTable)
|
||||
.where(and(eq(TeamTable.organizationId, payload.organization.id), eq(TeamTable.name, input.name)))
|
||||
.limit(1)
|
||||
|
||||
if (existingTeam[0]) {
|
||||
return c.json({ error: "team_exists", message: "That team already exists in this organization." }, 409)
|
||||
}
|
||||
|
||||
const teamId = createDenTypeId("team")
|
||||
const now = new Date()
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(TeamTable).values({
|
||||
id: teamId,
|
||||
name: input.name,
|
||||
organizationId: payload.organization.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
if (memberIds.length > 0) {
|
||||
await tx.insert(TeamMemberTable).values(
|
||||
memberIds.map((memberId) => ({
|
||||
id: createDenTypeId("teamMember"),
|
||||
teamId,
|
||||
orgMembershipId: memberId,
|
||||
createdAt: now,
|
||||
})),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return c.json({
|
||||
team: {
|
||||
id: teamId,
|
||||
organizationId: payload.organization.id,
|
||||
name: input.name,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
memberIds,
|
||||
},
|
||||
}, 201)
|
||||
},
|
||||
)
|
||||
|
||||
app.patch(
|
||||
"/v1/orgs/:orgId/teams/:teamId",
|
||||
requireUserMiddleware,
|
||||
paramValidator(orgTeamParamsSchema),
|
||||
resolveOrganizationContextMiddleware,
|
||||
jsonValidator(updateTeamSchema),
|
||||
async (c) => {
|
||||
const permission = ensureTeamManager(c)
|
||||
if (!permission.ok) {
|
||||
return c.json(permission.response, 403)
|
||||
}
|
||||
|
||||
const payload = c.get("organizationContext")
|
||||
const params = c.req.valid("param")
|
||||
const input = c.req.valid("json")
|
||||
|
||||
let teamId: TeamId
|
||||
try {
|
||||
teamId = parseTeamId(params.teamId)
|
||||
} catch {
|
||||
return c.json({ error: "team_not_found" }, 404)
|
||||
}
|
||||
|
||||
const teamRows = await db
|
||||
.select()
|
||||
.from(TeamTable)
|
||||
.where(and(eq(TeamTable.id, teamId), eq(TeamTable.organizationId, payload.organization.id)))
|
||||
.limit(1)
|
||||
|
||||
const team = teamRows[0]
|
||||
if (!team) {
|
||||
return c.json({ error: "team_not_found" }, 404)
|
||||
}
|
||||
|
||||
let memberIds: MemberId[] | undefined
|
||||
if (input.memberIds) {
|
||||
try {
|
||||
memberIds = parseMemberIds(input.memberIds)
|
||||
} catch {
|
||||
return c.json({ error: "member_not_found" }, 404)
|
||||
}
|
||||
|
||||
const membersBelongToOrg = await ensureMembersBelongToOrganization({
|
||||
organizationId: payload.organization.id,
|
||||
memberIds,
|
||||
})
|
||||
if (!membersBelongToOrg) {
|
||||
return c.json({ error: "member_not_found" }, 404)
|
||||
}
|
||||
}
|
||||
|
||||
const nextName = input.name ?? team.name
|
||||
const duplicate = await db
|
||||
.select({ id: TeamTable.id })
|
||||
.from(TeamTable)
|
||||
.where(and(eq(TeamTable.organizationId, payload.organization.id), eq(TeamTable.name, nextName)))
|
||||
.limit(1)
|
||||
|
||||
if (duplicate[0] && duplicate[0].id !== team.id) {
|
||||
return c.json({ error: "team_exists", message: "That team already exists in this organization." }, 409)
|
||||
}
|
||||
|
||||
const updatedAt = new Date()
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.update(TeamTable).set({ name: nextName, updatedAt }).where(eq(TeamTable.id, team.id))
|
||||
|
||||
if (memberIds) {
|
||||
await tx.delete(TeamMemberTable).where(eq(TeamMemberTable.teamId, team.id))
|
||||
if (memberIds.length > 0) {
|
||||
await tx.insert(TeamMemberTable).values(
|
||||
memberIds.map((memberId) => ({
|
||||
id: createDenTypeId("teamMember"),
|
||||
teamId: team.id,
|
||||
orgMembershipId: memberId,
|
||||
createdAt: updatedAt,
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return c.json({
|
||||
team: {
|
||||
...team,
|
||||
name: nextName,
|
||||
updatedAt,
|
||||
memberIds: memberIds ?? [],
|
||||
},
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
app.delete(
|
||||
"/v1/orgs/:orgId/teams/:teamId",
|
||||
requireUserMiddleware,
|
||||
paramValidator(orgTeamParamsSchema),
|
||||
resolveOrganizationContextMiddleware,
|
||||
async (c) => {
|
||||
const permission = ensureTeamManager(c)
|
||||
if (!permission.ok) {
|
||||
return c.json(permission.response, 403)
|
||||
}
|
||||
|
||||
const payload = c.get("organizationContext")
|
||||
const params = c.req.valid("param")
|
||||
|
||||
let teamId: TeamId
|
||||
try {
|
||||
teamId = parseTeamId(params.teamId)
|
||||
} catch {
|
||||
return c.json({ error: "team_not_found" }, 404)
|
||||
}
|
||||
|
||||
const teamRows = await db
|
||||
.select()
|
||||
.from(TeamTable)
|
||||
.where(and(eq(TeamTable.id, teamId), eq(TeamTable.organizationId, payload.organization.id)))
|
||||
.limit(1)
|
||||
|
||||
const team = teamRows[0]
|
||||
if (!team) {
|
||||
return c.json({ error: "team_not_found" }, 404)
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.delete(SkillHubMemberTable).where(eq(SkillHubMemberTable.teamId, team.id))
|
||||
await tx.delete(TeamMemberTable).where(eq(TeamMemberTable.teamId, team.id))
|
||||
await tx.delete(TeamTable).where(eq(TeamTable.id, team.id))
|
||||
})
|
||||
|
||||
return c.body(null, 204)
|
||||
},
|
||||
)
|
||||
}
|
||||
151
ee/apps/den-web/app/(den)/_components/ui/button.tsx
Normal file
151
ee/apps/den-web/app/(den)/_components/ui/button.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import type { ButtonHTMLAttributes, ElementType } from "react";
|
||||
|
||||
// ─── Variant / size tokens ────────────────────────────────────────────────────
|
||||
|
||||
export type ButtonVariant = "primary" | "secondary" | "destructive";
|
||||
export type ButtonSize = "md" | "sm";
|
||||
|
||||
const variantClasses: Record<ButtonVariant, string> = {
|
||||
primary:
|
||||
"bg-[#0f172a] text-white hover:bg-[#111c33]",
|
||||
secondary:
|
||||
"border border-gray-200 bg-white text-gray-700 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-900",
|
||||
destructive:
|
||||
"border border-red-200 text-red-600 hover:bg-red-50 hover:border-red-300",
|
||||
};
|
||||
|
||||
// md is sized to match the Shared Workspaces reference buttons (px-5 py-2.5 ≈ h-10)
|
||||
const sizeClasses: Record<ButtonSize, string> = {
|
||||
md: "h-10 px-5 text-[13px] gap-2",
|
||||
sm: "h-8 px-3.5 text-[12px] gap-1.5",
|
||||
};
|
||||
|
||||
// ─── buttonVariants helper (for <Link> / <a> elements) ───────────────────────
|
||||
|
||||
/**
|
||||
* Returns the className string for button styles.
|
||||
* Use this on <Link> and <a> elements that should look like buttons.
|
||||
*/
|
||||
export function buttonVariants({
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
className = "",
|
||||
}: {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
className?: string;
|
||||
} = {}): string {
|
||||
return [
|
||||
"inline-flex items-center justify-center rounded-full font-medium transition-colors",
|
||||
variantClasses[variant],
|
||||
sizeClasses[size],
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
// ─── Spinner ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function Spinner({ px }: { px: number }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
width={px}
|
||||
height={px}
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── DenButton ────────────────────────────────────────────────────────────────
|
||||
|
||||
export type DenButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
/**
|
||||
* Lucide icon component rendered on the left.
|
||||
* In loading state the icon is replaced by a spinner.
|
||||
*/
|
||||
icon?: ElementType<{ size?: number; className?: string; strokeWidth?: number }>;
|
||||
/**
|
||||
* Shows a spinner and forces the button into a disabled state.
|
||||
* - With icon: spinner replaces the icon; text stays visible.
|
||||
* - Without icon: text becomes invisible (preserving button width) and a
|
||||
* spinner appears centered over it.
|
||||
*/
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
export function DenButton({
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
icon: Icon,
|
||||
loading = false,
|
||||
disabled = false,
|
||||
children,
|
||||
className,
|
||||
...rest
|
||||
}: DenButtonProps) {
|
||||
const isDisabled = disabled || loading;
|
||||
const iconPx = size === "sm" ? 13 : 15;
|
||||
const hasText = children !== null && children !== undefined;
|
||||
// No-icon loading: hide text but keep its width, overlay centered spinner
|
||||
const noIconLoading = loading && !Icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
{...rest}
|
||||
type={rest.type ?? "button"}
|
||||
disabled={isDisabled}
|
||||
className={[
|
||||
"relative inline-flex items-center justify-center rounded-full font-medium transition-colors",
|
||||
variantClasses[variant],
|
||||
sizeClasses[size],
|
||||
isDisabled ? "cursor-not-allowed opacity-70" : "",
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{/* Leading icon slot ─ shows icon normally, or spinner when loading */}
|
||||
{Icon && !loading && (
|
||||
<Icon size={iconPx} strokeWidth={1.75} aria-hidden="true" />
|
||||
)}
|
||||
{Icon && loading && <Spinner px={iconPx} />}
|
||||
|
||||
{/* Text — invisible (not removed) when in no-icon loading state */}
|
||||
{hasText && (
|
||||
<span className={noIconLoading ? "invisible" : undefined}>
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Centered overlay spinner when there is no icon */}
|
||||
{noIconLoading && (
|
||||
<span className="absolute inset-0 flex items-center justify-center">
|
||||
<Spinner px={iconPx} />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import type { ElementType } from "react";
|
||||
import { PaperMeshGradient } from "@openwork/ui/react";
|
||||
import { Dithering } from "@paper-design/shaders-react";
|
||||
|
||||
/**
|
||||
* DashboardPageTemplate
|
||||
*
|
||||
* A consistent page shell for all org dashboard pages.
|
||||
* Provides:
|
||||
* - A gradient hero card (icon + badge + title)
|
||||
* - A description line below the card
|
||||
* - A children slot for page-specific content
|
||||
*
|
||||
* Caller controls only the gradient `colors` tuple — everything else
|
||||
* (distortion, swirl, grain, speed, frame, dithering overlay) is fixed
|
||||
* so every page looks coherent.
|
||||
*/
|
||||
|
||||
export type DashboardPageTemplateProps = {
|
||||
/** Lucide (or any) icon component rendered inside the frosted glass icon box */
|
||||
icon: ElementType<{
|
||||
size?: number;
|
||||
className?: string;
|
||||
strokeWidth?: number;
|
||||
}>;
|
||||
/** Short label rendered as a frosted pill badge above the title. Omit to hide. */
|
||||
badgeLabel?: string;
|
||||
/** Page heading rendered large inside the card */
|
||||
title: string;
|
||||
/** One-liner rendered in gray below the card, above children */
|
||||
description: string;
|
||||
/**
|
||||
* Exactly 4 CSS hex colors for the mesh gradient.
|
||||
* Tip: vary hue across pages so each section feels distinct at a glance.
|
||||
*/
|
||||
colors: [string, string, string, string];
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function DashboardPageTemplate({
|
||||
icon: Icon,
|
||||
badgeLabel,
|
||||
title,
|
||||
description,
|
||||
colors,
|
||||
children,
|
||||
}: DashboardPageTemplateProps) {
|
||||
return (
|
||||
<div className="mx-auto max-w-[860px] p-8">
|
||||
{/* ── Gradient hero card ── */}
|
||||
<div className="relative mb-8 flex h-[200px] items-center overflow-hidden rounded-3xl border border-gray-100 px-10">
|
||||
{/* Background layers: mesh gradient wrapped in a dithering texture */}
|
||||
<div className="absolute inset-0 z-0">
|
||||
<Dithering
|
||||
speed={0}
|
||||
shape="warp"
|
||||
type="4x4"
|
||||
size={2.5}
|
||||
scale={1}
|
||||
frame={41112.4}
|
||||
colorBack="#00000000"
|
||||
colorFront="#FEFEFE"
|
||||
style={{
|
||||
backgroundColor: "#0f172a",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<PaperMeshGradient
|
||||
speed={0.1}
|
||||
distortion={0.8}
|
||||
swirl={0.1}
|
||||
grainMixer={0}
|
||||
grainOverlay={0}
|
||||
frame={176868.9}
|
||||
colors={colors}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
/>
|
||||
</Dithering>
|
||||
</div>
|
||||
|
||||
{/* Icon — top right */}
|
||||
<div className="absolute right-8 top-8 z-10 flex h-12 w-12 items-center justify-center rounded-xl border border-white/30 bg-white/20 backdrop-blur-md">
|
||||
<Icon size={24} className="text-white" strokeWidth={1.5} />
|
||||
</div>
|
||||
|
||||
{/* Badge (optional) + Title — bottom left */}
|
||||
<div className="absolute bottom-8 left-10 z-10 flex flex-col items-start gap-2">
|
||||
{badgeLabel ? (
|
||||
<span className="rounded-full border border-white/20 bg-white/20 px-2.5 py-1 text-[10px] uppercase tracking-[1px] text-white backdrop-blur-md">
|
||||
{badgeLabel}
|
||||
</span>
|
||||
) : null}
|
||||
<h1 className="text-[28px] font-medium tracking-[-0.5px] text-white">
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Description ── */}
|
||||
<p className="mb-6 text-[14px] text-gray-500">{description}</p>
|
||||
|
||||
{/* ── Page content ── */}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
ee/apps/den-web/app/(den)/_components/ui/input.tsx
Normal file
90
ee/apps/den-web/app/(den)/_components/ui/input.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import type { ElementType, InputHTMLAttributes } from "react";
|
||||
|
||||
export type DenInputProps = Omit<InputHTMLAttributes<HTMLInputElement>, "disabled"> & {
|
||||
/**
|
||||
* Optional Lucide icon component rendered on the left.
|
||||
* When omitted, no icon is shown and no extra left padding is added.
|
||||
*/
|
||||
icon?: ElementType<{ size?: number; className?: string }>;
|
||||
/**
|
||||
* Pixel size of the icon. Defaults to 16.
|
||||
* Use 20 for larger search fields so the icon stays proportional.
|
||||
* Left position and left-padding are derived automatically.
|
||||
*/
|
||||
iconSize?: number;
|
||||
/**
|
||||
* Disables the input and dims it to 60 % opacity.
|
||||
* Forwarded as the native `disabled` attribute.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* DenInput
|
||||
*
|
||||
* Consistent text input for all dashboard pages, based on the
|
||||
* Shared Workspaces compact search field.
|
||||
*
|
||||
* Defaults: rounded-lg · py-2.5 · px-4 · text-[14px]
|
||||
* Icon: auto-positions and adjusts left padding.
|
||||
* No className needed at the call site — override only when necessary.
|
||||
*/
|
||||
export function DenInput({
|
||||
icon: Icon,
|
||||
iconSize = 16,
|
||||
disabled = false,
|
||||
className,
|
||||
...rest
|
||||
}: DenInputProps) {
|
||||
const isLargeIcon = iconSize > 16;
|
||||
const iconLeft = isLargeIcon ? "left-5" : "left-3";
|
||||
// inject icon left-padding only if the caller hasn't specified one
|
||||
const iconPl = Icon
|
||||
? className?.includes("pl-")
|
||||
? ""
|
||||
: isLargeIcon
|
||||
? "pl-14"
|
||||
: "pl-9"
|
||||
: "";
|
||||
|
||||
const input = (
|
||||
<input
|
||||
{...rest}
|
||||
disabled={disabled}
|
||||
className={[
|
||||
// base visual style
|
||||
"w-full rounded-lg border border-gray-200 bg-white",
|
||||
"py-2.5 px-4 text-[14px] text-gray-900",
|
||||
"outline-none transition-all placeholder:text-gray-400",
|
||||
"focus:border-gray-300 focus:ring-2 focus:ring-gray-900/5",
|
||||
// disabled state
|
||||
disabled ? "cursor-not-allowed opacity-60" : "",
|
||||
// icon left-padding (overrides px-4 left side)
|
||||
iconPl,
|
||||
// caller overrides
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!Icon) return input;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div
|
||||
className={`pointer-events-none absolute inset-y-0 ${iconLeft} flex items-center`}
|
||||
>
|
||||
<Icon
|
||||
size={iconSize}
|
||||
className={disabled ? "text-gray-300" : "text-gray-400"}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
{input}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
ee/apps/den-web/app/(den)/_components/ui/tabs.tsx
Normal file
60
ee/apps/den-web/app/(den)/_components/ui/tabs.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import type { ElementType } from "react";
|
||||
|
||||
export type TabItem<T extends string> = {
|
||||
value: T;
|
||||
label: string;
|
||||
icon?: ElementType<{ className?: string }>;
|
||||
count?: number;
|
||||
};
|
||||
|
||||
type UnderlineTabsProps<T extends string> = {
|
||||
tabs: readonly TabItem<T>[];
|
||||
activeTab: T;
|
||||
onChange: (value: T) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function UnderlineTabs<T extends string>({
|
||||
tabs,
|
||||
activeTab,
|
||||
onChange,
|
||||
className = "",
|
||||
}: UnderlineTabsProps<T>) {
|
||||
return (
|
||||
<div className={`border-b border-gray-200 ${className}`}>
|
||||
<nav className="-mb-px flex flex-wrap gap-6" role="tablist">
|
||||
{tabs.map(({ value, label, icon: Icon, count }) => {
|
||||
const selected = activeTab === value;
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={selected}
|
||||
onClick={() => onChange(value)}
|
||||
className={`inline-flex items-center gap-2 border-b-2 pb-3 text-[14px] font-medium transition-colors ${
|
||||
selected
|
||||
? "border-[#0f172a] text-[#0f172a]"
|
||||
: "border-transparent text-gray-400 hover:text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{Icon ? <Icon className="h-4 w-4" /> : null}
|
||||
{label}
|
||||
{count !== undefined && count > 0 ? (
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${
|
||||
selected ? "bg-gray-100 text-gray-600" : "bg-gray-100 text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
ee/apps/den-web/app/(den)/_components/ui/textarea.tsx
Normal file
51
ee/apps/den-web/app/(den)/_components/ui/textarea.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import type { TextareaHTMLAttributes } from "react";
|
||||
|
||||
export type DenTextareaProps = Omit<
|
||||
TextareaHTMLAttributes<HTMLTextAreaElement>,
|
||||
"disabled"
|
||||
> & {
|
||||
/**
|
||||
* Number of visible text lines — sets the initial height.
|
||||
* Defaults to 4.
|
||||
*/
|
||||
rows?: number;
|
||||
/**
|
||||
* Disables the textarea and dims it to 60 % opacity.
|
||||
* Forwarded as the native `disabled` attribute.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* DenTextarea
|
||||
*
|
||||
* Matches DenInput styling exactly: same border, bg, focus ring,
|
||||
* placeholder, and disabled state. Height is controlled by `rows`.
|
||||
*/
|
||||
export function DenTextarea({
|
||||
rows = 4,
|
||||
disabled = false,
|
||||
className,
|
||||
...rest
|
||||
}: DenTextareaProps) {
|
||||
return (
|
||||
<textarea
|
||||
{...rest}
|
||||
rows={rows}
|
||||
disabled={disabled}
|
||||
className={[
|
||||
"w-full rounded-lg border border-gray-200 bg-white",
|
||||
"px-4 py-2.5 text-[14px] text-gray-900",
|
||||
"outline-none transition-all placeholder:text-gray-400",
|
||||
"focus:border-gray-300 focus:ring-2 focus:ring-gray-900/5",
|
||||
"resize-none",
|
||||
disabled ? "cursor-not-allowed opacity-60" : "",
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,22 @@ export type DenOrgInvitation = {
|
||||
createdAt: string | null;
|
||||
};
|
||||
|
||||
export type DenOrgTeam = {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
memberIds: string[];
|
||||
};
|
||||
|
||||
export type DenCurrentMemberTeam = {
|
||||
id: string;
|
||||
name: string;
|
||||
organizationId: string;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export type DenInvitationPreview = {
|
||||
invitation: {
|
||||
id: string;
|
||||
@@ -81,6 +97,8 @@ export type DenOrgContext = {
|
||||
members: DenOrgMember[];
|
||||
invitations: DenOrgInvitation[];
|
||||
roles: DenOrgRole[];
|
||||
teams: DenOrgTeam[];
|
||||
currentMemberTeams: DenCurrentMemberTeam[];
|
||||
};
|
||||
|
||||
export const DEN_ROLE_PERMISSION_OPTIONS = {
|
||||
@@ -142,6 +160,7 @@ export function getOrgAccessFlags(roleValue: string, isOwner: boolean) {
|
||||
canCancelInvitations: isAdmin,
|
||||
canManageMembers: isOwner,
|
||||
canManageRoles: isOwner,
|
||||
canManageTeams: isAdmin,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -185,6 +204,34 @@ export function getBillingRoute(orgSlug: string): string {
|
||||
return `${getOrgDashboardRoute(orgSlug)}/billing`;
|
||||
}
|
||||
|
||||
export function getSkillHubsRoute(orgSlug: string): string {
|
||||
return `${getOrgDashboardRoute(orgSlug)}/skill-hubs`;
|
||||
}
|
||||
|
||||
export function getSkillHubRoute(orgSlug: string, skillHubId: string): string {
|
||||
return `${getSkillHubsRoute(orgSlug)}/${encodeURIComponent(skillHubId)}`;
|
||||
}
|
||||
|
||||
export function getEditSkillHubRoute(orgSlug: string, skillHubId: string): string {
|
||||
return `${getSkillHubRoute(orgSlug, skillHubId)}/edit`;
|
||||
}
|
||||
|
||||
export function getNewSkillHubRoute(orgSlug: string): string {
|
||||
return `${getSkillHubsRoute(orgSlug)}/new`;
|
||||
}
|
||||
|
||||
export function getSkillDetailRoute(orgSlug: string, skillId: string): string {
|
||||
return `${getSkillHubsRoute(orgSlug)}/skills/${encodeURIComponent(skillId)}`;
|
||||
}
|
||||
|
||||
export function getEditSkillRoute(orgSlug: string, skillId: string): string {
|
||||
return `${getSkillDetailRoute(orgSlug, skillId)}/edit`;
|
||||
}
|
||||
|
||||
export function getNewSkillRoute(orgSlug: string): string {
|
||||
return `${getSkillHubsRoute(orgSlug)}/skills/new`;
|
||||
}
|
||||
|
||||
export function parseOrgListPayload(payload: unknown): {
|
||||
orgs: DenOrgSummary[];
|
||||
activeOrgId: string | null;
|
||||
@@ -339,6 +386,53 @@ export function parseOrgContextPayload(payload: unknown): DenOrgContext | null {
|
||||
.filter((entry): entry is DenOrgRole => entry !== null)
|
||||
: [];
|
||||
|
||||
const teams = Array.isArray(payload.teams)
|
||||
? payload.teams
|
||||
.map((entry) => {
|
||||
if (!isRecord(entry) || typeof entry.id !== "string" || typeof entry.name !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const memberIds = Array.isArray(entry.memberIds)
|
||||
? entry.memberIds.filter((value): value is string => typeof value === "string")
|
||||
: [];
|
||||
|
||||
return {
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
createdAt: asIsoString(entry.createdAt),
|
||||
updatedAt: asIsoString(entry.updatedAt),
|
||||
memberIds,
|
||||
} satisfies DenOrgTeam;
|
||||
})
|
||||
.filter((entry): entry is DenOrgTeam => entry !== null)
|
||||
: [];
|
||||
|
||||
const currentMemberTeams = Array.isArray(payload.currentMemberTeams)
|
||||
? payload.currentMemberTeams
|
||||
.map((entry) => {
|
||||
if (!isRecord(entry)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = asString(entry.id);
|
||||
const name = asString(entry.name);
|
||||
const organizationId = asString(entry.organizationId);
|
||||
if (!id || !name || !organizationId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
organizationId,
|
||||
createdAt: asIsoString(entry.createdAt),
|
||||
updatedAt: asIsoString(entry.updatedAt),
|
||||
} satisfies DenCurrentMemberTeam;
|
||||
})
|
||||
.filter((entry): entry is DenCurrentMemberTeam => entry !== null)
|
||||
: [];
|
||||
|
||||
return {
|
||||
organization: {
|
||||
id: organizationId,
|
||||
@@ -359,6 +453,8 @@ export function parseOrgContextPayload(payload: unknown): DenOrgContext | null {
|
||||
members,
|
||||
invitations,
|
||||
roles,
|
||||
teams,
|
||||
currentMemberTeams,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Bot,
|
||||
Box,
|
||||
Check,
|
||||
ChevronDown,
|
||||
@@ -15,9 +16,11 @@ import {
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import { PaperMeshGradient } from "@openwork/ui/react";
|
||||
import { Dithering } from "@paper-design/shaders-react";
|
||||
import { DenInput } from "../../../../_components/ui/input";
|
||||
import { DashboardPageTemplate } from "../../../../_components/ui/dashboard-page-template";
|
||||
import { DenButton, buttonVariants } from "../../../../_components/ui/button";
|
||||
import {
|
||||
OPENWORK_APP_CONNECT_BASE_URL,
|
||||
buildOpenworkAppConnectUrl,
|
||||
@@ -393,60 +396,24 @@ export function BackgroundAgentsScreen() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[860px] p-8">
|
||||
<div className="relative mb-8 flex min-h-[180px] items-center overflow-hidden rounded-3xl border border-gray-100 px-10">
|
||||
<div className="absolute inset-0 z-0">
|
||||
<Dithering
|
||||
speed={0}
|
||||
shape="warp"
|
||||
type="4x4"
|
||||
size={2.5}
|
||||
scale={1}
|
||||
frame={5213.4}
|
||||
colorBack="#00000000"
|
||||
colorFront="#FEFEFE"
|
||||
style={{ backgroundColor: "#23301C", width: "100%", height: "100%" }}
|
||||
>
|
||||
<PaperMeshGradient
|
||||
speed={0}
|
||||
distortion={0.8}
|
||||
swirl={0.1}
|
||||
grainMixer={0}
|
||||
grainOverlay={0}
|
||||
frame={176868.9}
|
||||
colors={["#E9FFE0", "#3E9A1D", "#B3F750", "#51F0A3"]}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
/>
|
||||
</Dithering>
|
||||
</div>
|
||||
<div className="relative z-10 flex flex-col items-start gap-3">
|
||||
<div>
|
||||
<span className="mb-2 inline-block rounded-full border border-white/20 bg-white/20 px-2.5 py-1 text-[10px] font-medium uppercase tracking-[1px] text-white backdrop-blur-md">
|
||||
Alpha
|
||||
</span>
|
||||
<h1 className="mb-1.5 text-[26px] font-medium tracking-[-0.5px] text-white">
|
||||
Shared Workspaces
|
||||
</h1>
|
||||
<p className="max-w-[500px] text-[14px] text-white/80">
|
||||
Keep selected workflows running in the background without asking each teammate to run them locally. Available for selected workflows while the product continues to evolve.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DashboardPageTemplate
|
||||
icon={Bot}
|
||||
badgeLabel="Alpha"
|
||||
title="Shared Workspaces"
|
||||
description="Keep selected workflows running in the background without asking each teammate to run them locally."
|
||||
colors={["#E9FFE0", "#3E9A1D", "#B3F750", "#51F0A3"]}
|
||||
>
|
||||
<div className="mb-10 flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
<DenButton
|
||||
icon={Plus}
|
||||
loading={launchBusy}
|
||||
onClick={() => void handleAddWorkspace()}
|
||||
disabled={launchBusy}
|
||||
className="flex items-center gap-2 rounded-full bg-gray-900 px-5 py-2.5 text-[13px] font-medium text-white shadow-sm transition-colors hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Plus size={15} />
|
||||
{launchBusy ? "Adding workspace..." : "Add workspace"}
|
||||
</button>
|
||||
Add workspace
|
||||
</DenButton>
|
||||
<Link
|
||||
href={getSharedSetupsRoute(orgSlug)}
|
||||
className="flex items-center gap-2 rounded-full border border-gray-200 bg-white px-5 py-2.5 text-[13px] font-medium text-gray-700 shadow-sm transition-colors hover:bg-gray-50"
|
||||
className={buttonVariants({ variant: "secondary" })}
|
||||
>
|
||||
Open shared setups
|
||||
</Link>
|
||||
@@ -468,30 +435,13 @@ export function BackgroundAgentsScreen() {
|
||||
<h2 className="text-[15px] font-medium tracking-[-0.2px] text-gray-900">
|
||||
Current workspaces
|
||||
</h2>
|
||||
<div className="relative w-full max-w-[240px]">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-3 flex items-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-gray-400"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
<div className="w-full max-w-[240px]">
|
||||
<DenInput
|
||||
type="text"
|
||||
icon={Search}
|
||||
value={workerQuery}
|
||||
onChange={(event) => setWorkerQuery(event.target.value)}
|
||||
placeholder="Search workspaces..."
|
||||
className="w-full rounded-lg border border-gray-200 bg-white py-2 pl-9 pr-4 text-[13px] text-gray-900 outline-none transition-all placeholder:text-gray-400 focus:border-gray-300 focus:ring-2 focus:ring-gray-900/5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -534,6 +484,6 @@ export function BackgroundAgentsScreen() {
|
||||
{workersLoadedOnce && workersBusy ? (
|
||||
<p className="mt-4 text-[12px] text-gray-400">Refreshing workspaces…</p>
|
||||
) : null}
|
||||
</div>
|
||||
</DashboardPageTemplate>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { CreditCard } from "lucide-react";
|
||||
import { DenButton, buttonVariants } from "../../../../_components/ui/button";
|
||||
import {
|
||||
formatIsoDate,
|
||||
formatMoneyMinor,
|
||||
formatRecurringInterval,
|
||||
formatSubscriptionStatus,
|
||||
} from "../../../../_lib/den-flow";
|
||||
import { DashboardPageTemplate } from "../../../../_components/ui/dashboard-page-template";
|
||||
import { useDenFlow } from "../../../../_providers/den-flow-provider";
|
||||
|
||||
export function BillingDashboardScreen() {
|
||||
@@ -40,11 +43,16 @@ export function BillingDashboardScreen() {
|
||||
|
||||
if (!sessionHydrated) {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[960px] px-6 py-8 md:px-8">
|
||||
<DashboardPageTemplate
|
||||
icon={CreditCard}
|
||||
title="Billing"
|
||||
description="Manage your plan, view usage, and update payment details."
|
||||
colors={["#EFF6FF", "#1E3A5F", "#3B82F6", "#93C5FD"]}
|
||||
>
|
||||
<div className="rounded-[20px] border border-gray-100 bg-white px-5 py-8 text-[14px] text-gray-500">
|
||||
Checking billing details…
|
||||
</div>
|
||||
</div>
|
||||
</DashboardPageTemplate>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,16 +79,12 @@ export function BillingDashboardScreen() {
|
||||
: "Not available";
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[960px] px-6 py-8 md:px-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="mb-2 text-[28px] font-semibold tracking-[-0.5px] text-gray-900">
|
||||
Billing
|
||||
</h1>
|
||||
<p className="text-[15px] text-gray-500">
|
||||
Manage your billing information and subscription settings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DashboardPageTemplate
|
||||
icon={CreditCard}
|
||||
title="Billing"
|
||||
description="Manage your plan, view usage, and update payment details."
|
||||
colors={["#EFF6FF", "#1E3A5F", "#3B82F6", "#93C5FD"]}
|
||||
>
|
||||
{billingError ? (
|
||||
<div className="mb-6 rounded-[20px] border border-red-200 bg-red-50 px-4 py-3 text-[13px] text-red-700">
|
||||
{billingError}
|
||||
@@ -139,47 +143,25 @@ export function BillingDashboardScreen() {
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{effectiveCheckoutUrl && !billingSummary?.hasActivePlan ? (
|
||||
<a
|
||||
href={effectiveCheckoutUrl}
|
||||
rel="noreferrer"
|
||||
className="rounded-full bg-gray-900 px-5 py-2.5 text-[14px] font-medium text-white transition-colors hover:bg-gray-800"
|
||||
>
|
||||
<a href={effectiveCheckoutUrl} rel="noreferrer" className={buttonVariants({ variant: "primary" })}>
|
||||
Purchase worker
|
||||
</a>
|
||||
) : null}
|
||||
|
||||
{billingSummary?.portalUrl ? (
|
||||
<a
|
||||
href={billingSummary.portalUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded-full border border-gray-200 bg-white px-5 py-2.5 text-[14px] font-medium text-gray-700 transition-colors hover:bg-gray-50"
|
||||
>
|
||||
<a href={billingSummary.portalUrl} target="_blank" rel="noreferrer" className={buttonVariants({ variant: "secondary" })}>
|
||||
Open billing portal
|
||||
</a>
|
||||
) : null}
|
||||
|
||||
{billingSummary?.hasActivePlan ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void handleSubscriptionCancellation(
|
||||
!Boolean(subscription?.cancelAtPeriodEnd),
|
||||
)
|
||||
}
|
||||
disabled={billingSubscriptionBusy}
|
||||
className={`rounded-full px-5 py-2.5 text-[14px] font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${
|
||||
subscription?.cancelAtPeriodEnd
|
||||
? "border border-gray-200 bg-white text-gray-700 hover:bg-gray-50"
|
||||
: "border border-red-200 bg-white text-red-600 hover:bg-red-50"
|
||||
}`}
|
||||
<DenButton
|
||||
variant={subscription?.cancelAtPeriodEnd ? "secondary" : "destructive"}
|
||||
loading={billingSubscriptionBusy}
|
||||
onClick={() => void handleSubscriptionCancellation(!Boolean(subscription?.cancelAtPeriodEnd))}
|
||||
>
|
||||
{billingSubscriptionBusy
|
||||
? "Updating..."
|
||||
: subscription?.cancelAtPeriodEnd
|
||||
? "Resume plan"
|
||||
: "Cancel plan"}
|
||||
</button>
|
||||
{subscription?.cancelAtPeriodEnd ? "Resume plan" : "Cancel plan"}
|
||||
</DenButton>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -214,25 +196,20 @@ export function BillingDashboardScreen() {
|
||||
</div>
|
||||
|
||||
{billingSummary?.portalUrl ? (
|
||||
<a
|
||||
href={billingSummary.portalUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="whitespace-nowrap rounded-full border border-gray-200 bg-white px-5 py-2.5 text-[14px] font-medium text-gray-700 transition-colors hover:bg-gray-50"
|
||||
>
|
||||
<a href={billingSummary.portalUrl} target="_blank" rel="noreferrer" className={buttonVariants({ variant: "secondary", size: "sm" })}>
|
||||
View invoices
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
<DenButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
loading={billingBusy || billingCheckoutBusy}
|
||||
onClick={() => void refreshBilling({ includeCheckout: true, quiet: false })}
|
||||
disabled={billingBusy || billingCheckoutBusy}
|
||||
className="whitespace-nowrap rounded-full border border-gray-200 bg-white px-5 py-2.5 text-[14px] font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{billingBusy || billingCheckoutBusy ? "Refreshing..." : "Refresh billing"}
|
||||
</button>
|
||||
Refresh billing
|
||||
</DenButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DashboardPageTemplate>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Cpu } from "lucide-react";
|
||||
import { PaperMeshGradient } from "@openwork/ui/react";
|
||||
import { Dithering } from "@paper-design/shaders-react";
|
||||
import { DashboardPageTemplate } from "../../../../_components/ui/dashboard-page-template";
|
||||
|
||||
const comingSoonItems = [
|
||||
"Standardize provider access across your team.",
|
||||
@@ -12,51 +11,13 @@ const comingSoonItems = [
|
||||
|
||||
export function CustomLlmProvidersScreen() {
|
||||
return (
|
||||
<div className="mx-auto max-w-[860px] p-8">
|
||||
<div className="relative mb-8 flex h-[200px] items-center overflow-hidden rounded-3xl border border-gray-100 px-10">
|
||||
<div className="absolute inset-0 z-0">
|
||||
<Dithering
|
||||
speed={0}
|
||||
shape="warp"
|
||||
type="4x4"
|
||||
size={2.5}
|
||||
scale={1}
|
||||
frame={41112.4}
|
||||
colorBack="#00000000"
|
||||
colorFront="#FEFEFE"
|
||||
style={{ backgroundColor: "#1C2A30", width: "100%", height: "100%" }}
|
||||
>
|
||||
<PaperMeshGradient
|
||||
speed={0.1}
|
||||
distortion={0.8}
|
||||
swirl={0.1}
|
||||
grainMixer={0}
|
||||
grainOverlay={0}
|
||||
frame={176868.9}
|
||||
colors={["#E0FCFF", "#1D7B9A", "#50F7D4", "#518EF0"]}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
/>
|
||||
</Dithering>
|
||||
</div>
|
||||
<div className="relative z-10 flex flex-col items-start gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl border border-white/30 bg-white/20 backdrop-blur-md">
|
||||
<Cpu size={24} className="text-white" strokeWidth={1.5} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="mb-2 inline-block rounded-full border border-white/20 bg-white/20 px-2.5 py-1 text-[10px] uppercase tracking-[1px] text-white backdrop-blur-md">
|
||||
Coming soon
|
||||
</span>
|
||||
<h1 className="text-[28px] font-medium tracking-[-0.5px] text-white">
|
||||
Custom LLMs
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mb-6 text-[14px] text-gray-500">
|
||||
Standardize provider access for your team.
|
||||
</p>
|
||||
|
||||
<DashboardPageTemplate
|
||||
icon={Cpu}
|
||||
badgeLabel="Coming soon"
|
||||
title="Custom LLMs"
|
||||
description="Standardize provider access for your team."
|
||||
colors={["#E0FCFF", "#1D7B9A", "#50F7D4", "#518EF0"]}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{comingSoonItems.map((text) => (
|
||||
<div
|
||||
@@ -70,6 +31,6 @@ export function CustomLlmProvidersScreen() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</DashboardPageTemplate>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
BookOpen,
|
||||
Bot,
|
||||
ChevronDown,
|
||||
CreditCard,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
getMembersRoute,
|
||||
getOrgDashboardRoute,
|
||||
getSharedSetupsRoute,
|
||||
getSkillHubsRoute,
|
||||
} from "../../../../_lib/den-org";
|
||||
import { useOrgDashboard } from "../_providers/org-dashboard-provider";
|
||||
import { OPENWORK_DOCS_URL, buildDenFeedbackUrl } from "./shared-setup-data";
|
||||
@@ -100,6 +102,9 @@ function getDashboardPageTitle(pathname: string, orgSlug: string | null) {
|
||||
if (pathname.startsWith(getCustomLlmProvidersRoute(orgSlug))) {
|
||||
return "Custom LLMs";
|
||||
}
|
||||
if (pathname.startsWith(getSkillHubsRoute(orgSlug))) {
|
||||
return "Skill Hubs";
|
||||
}
|
||||
if (pathname.startsWith(getBillingRoute(orgSlug)) || pathname === "/checkout") {
|
||||
return "Billing";
|
||||
}
|
||||
@@ -138,11 +143,6 @@ export function OrgDashboardShell({ children }: { children: React.ReactNode }) {
|
||||
label: "Team Templates",
|
||||
icon: Share2,
|
||||
},
|
||||
{
|
||||
href: activeOrg ? getMembersRoute(activeOrg.slug) : "#",
|
||||
label: "Members",
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
href: activeOrg ? getBackgroundAgentsRoute(activeOrg.slug) : "#",
|
||||
label: "Shared Workspace",
|
||||
@@ -155,6 +155,17 @@ export function OrgDashboardShell({ children }: { children: React.ReactNode }) {
|
||||
icon: Cpu,
|
||||
badge: "Soon",
|
||||
},
|
||||
{
|
||||
href: activeOrg ? getSkillHubsRoute(activeOrg.slug) : "#",
|
||||
label: "Skill Hubs",
|
||||
icon: BookOpen,
|
||||
badge: "New",
|
||||
},
|
||||
{
|
||||
href: activeOrg ? getMembersRoute(activeOrg.slug) : "#",
|
||||
label: "Members",
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
href: activeOrg ? getBillingRoute(activeOrg.slug) : "/checkout",
|
||||
label: "Billing",
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import { ArrowLeft, FileText, Pencil } from "lucide-react";
|
||||
import { PaperMeshGradient } from "@openwork/ui/react";
|
||||
import { buttonVariants } from "../../../../_components/ui/button";
|
||||
import {
|
||||
getEditSkillRoute,
|
||||
getSkillHubsRoute,
|
||||
} from "../../../../_lib/den-org";
|
||||
import { useOrgDashboard } from "../_providers/org-dashboard-provider";
|
||||
import {
|
||||
formatSkillTimestamp,
|
||||
getSkillBodyText,
|
||||
getSkillVisibilityLabel,
|
||||
parseSkillDraft,
|
||||
useOrgSkillLibrary,
|
||||
} from "./skill-hub-data";
|
||||
|
||||
export function SkillDetailScreen({ skillId }: { skillId: string }) {
|
||||
const { orgId, orgSlug } = useOrgDashboard();
|
||||
const { skills, busy, error } = useOrgSkillLibrary(orgId);
|
||||
const skill = useMemo(
|
||||
() => skills.find((entry) => entry.id === skillId) ?? null,
|
||||
[skillId, skills],
|
||||
);
|
||||
|
||||
if (busy && !skill) {
|
||||
return (
|
||||
<div className="mx-auto max-w-[900px] px-6 py-8 md:px-8">
|
||||
<div className="rounded-xl border border-gray-100 bg-white px-5 py-8 text-[13px] text-gray-400">
|
||||
Loading skill details...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!skill) {
|
||||
return (
|
||||
<div className="mx-auto max-w-[900px] px-6 py-8 md:px-8">
|
||||
<div className="rounded-xl border border-red-100 bg-red-50 px-5 py-3.5 text-[13px] text-red-600">
|
||||
{error ?? "That skill could not be found."}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const draft = parseSkillDraft(skill.skillText, {
|
||||
name: skill.title,
|
||||
description: skill.description,
|
||||
});
|
||||
const skillBody = getSkillBodyText(skill.skillText, {
|
||||
name: skill.title,
|
||||
description: skill.description,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[900px] px-6 py-8 md:px-8">
|
||||
|
||||
{/* Nav */}
|
||||
<div className="mb-6 flex items-center justify-between gap-4">
|
||||
<Link
|
||||
href={getSkillHubsRoute(orgSlug)}
|
||||
className="inline-flex items-center gap-1.5 text-[13px] text-gray-400 transition hover:text-gray-700"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
</Link>
|
||||
|
||||
{skill.canManage ? (
|
||||
<Link
|
||||
href={getEditSkillRoute(orgSlug, skill.id)}
|
||||
className={buttonVariants({ variant: "secondary", size: "sm" })}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Edit Skill
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5">
|
||||
|
||||
{/* ── Main card ── */}
|
||||
<section className="overflow-hidden rounded-2xl border border-gray-100 bg-white">
|
||||
|
||||
{/* Gradient header — seeded by skill id */}
|
||||
<div className="relative h-40 overflow-hidden border-b border-gray-100">
|
||||
<div className="absolute inset-0">
|
||||
<PaperMeshGradient seed={skill.id} speed={0} />
|
||||
</div>
|
||||
<div className="absolute bottom-[-20px] left-6 flex h-14 w-14 items-center justify-center rounded-[18px] border border-white/60 bg-white shadow-[0_12px_24px_-12px_rgba(15,23,42,0.3)]">
|
||||
<FileText className="h-6 w-6 text-gray-700" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 pb-6 pt-10">
|
||||
{/* Title row with inline visibility label */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<h1 className="text-[18px] font-semibold text-gray-900">{skill.title}</h1>
|
||||
<span className="mt-0.5 shrink-0 rounded-full bg-gray-100 px-3 py-1 text-[12px] text-gray-500">
|
||||
{getSkillVisibilityLabel(skill.shared)}
|
||||
</span>
|
||||
</div>
|
||||
{skill.description ? (
|
||||
<p className="mt-1.5 text-[13px] leading-relaxed text-gray-400">
|
||||
{skill.description}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-2 text-[12px] text-gray-300">
|
||||
Updated {formatSkillTimestamp(skill.updatedAt)}
|
||||
</p>
|
||||
|
||||
{/* Skill definition */}
|
||||
<div className="mt-6 border-t border-gray-100 pt-5">
|
||||
<p className="mb-3 text-[11px] font-medium uppercase tracking-wide text-gray-400">
|
||||
Instructions
|
||||
</p>
|
||||
<div className="overflow-x-auto rounded-xl border border-gray-100 bg-gray-50 px-4 py-4">
|
||||
<pre className="whitespace-pre-wrap font-mono text-[13px] leading-7 text-gray-700">
|
||||
{skillBody}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ArrowLeft, Upload } from "lucide-react";
|
||||
import { DenButton } from "../../../../_components/ui/button";
|
||||
import { DenInput } from "../../../../_components/ui/input";
|
||||
import { DenTextarea } from "../../../../_components/ui/textarea";
|
||||
import { getErrorMessage, requestJson } from "../../../../_lib/den-flow";
|
||||
import {
|
||||
getSkillDetailRoute,
|
||||
getSkillHubsRoute,
|
||||
} from "../../../../_lib/den-org";
|
||||
import { useOrgDashboard } from "../_providers/org-dashboard-provider";
|
||||
import {
|
||||
buildSkillText,
|
||||
parseSkillDraft,
|
||||
useOrgSkillLibrary,
|
||||
} from "./skill-hub-data";
|
||||
|
||||
type SkillEditorMode = "manual" | "upload";
|
||||
type SkillVisibility = "private" | "org" | "public";
|
||||
|
||||
export function SkillEditorScreen({ skillId }: { skillId?: string }) {
|
||||
const router = useRouter();
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const { orgId, orgSlug } = useOrgDashboard();
|
||||
const { skills, busy, error } = useOrgSkillLibrary(orgId);
|
||||
const skill = useMemo(
|
||||
() => (skillId ? skills.find((entry) => entry.id === skillId) ?? null : null),
|
||||
[skillId, skills],
|
||||
);
|
||||
const [mode, setMode] = useState<SkillEditorMode>("manual");
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [details, setDetails] = useState("");
|
||||
const [visibility, setVisibility] = useState<SkillVisibility>("private");
|
||||
const [uploadedSkillText, setUploadedSkillText] = useState<string | null>(null);
|
||||
const [uploadedFileName, setUploadedFileName] = useState<string | null>(null);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const assembledPreview =
|
||||
mode === "upload" && uploadedSkillText?.trim()
|
||||
? uploadedSkillText
|
||||
: buildSkillText({ name, category: "", description, details });
|
||||
|
||||
useEffect(() => {
|
||||
if (!skillId) {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setDetails("");
|
||||
setVisibility("private");
|
||||
setUploadedSkillText(null);
|
||||
setUploadedFileName(null);
|
||||
setMode("manual");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!skill) return;
|
||||
|
||||
const draft = parseSkillDraft(skill.skillText, {
|
||||
name: skill.title,
|
||||
description: skill.description,
|
||||
});
|
||||
setName(draft.name || skill.title);
|
||||
setDescription(draft.description || skill.description || "");
|
||||
setDetails(draft.details || skill.skillText);
|
||||
setVisibility(
|
||||
skill.shared === "org" ? "org" : skill.shared === "public" ? "public" : "private",
|
||||
);
|
||||
setUploadedSkillText(null);
|
||||
setUploadedFileName(null);
|
||||
setMode("manual");
|
||||
}, [skill, skillId]);
|
||||
|
||||
async function saveSkill() {
|
||||
if (!orgId) { setSaveError("Organization not found."); return; }
|
||||
if (!name.trim()) { setSaveError("Enter a skill name."); return; }
|
||||
|
||||
const skillText =
|
||||
mode === "upload" && uploadedSkillText?.trim()
|
||||
? uploadedSkillText
|
||||
: buildSkillText({ name, category: "", description, details });
|
||||
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
const shared = visibility === "private" ? null : visibility;
|
||||
if (skillId) {
|
||||
const { response, payload } = await requestJson(
|
||||
`/v1/orgs/${encodeURIComponent(orgId)}/skills/${encodeURIComponent(skillId)}`,
|
||||
{ method: "PATCH", body: JSON.stringify({ skillText, shared }) },
|
||||
12000,
|
||||
);
|
||||
if (!response.ok) throw new Error(getErrorMessage(payload, `Failed to update skill (${response.status}).`));
|
||||
router.push(getSkillDetailRoute(orgSlug, skillId));
|
||||
} else {
|
||||
const { response, payload } = await requestJson(
|
||||
`/v1/orgs/${encodeURIComponent(orgId)}/skills`,
|
||||
{ method: "POST", body: JSON.stringify({ skillText, shared }) },
|
||||
12000,
|
||||
);
|
||||
if (!response.ok) throw new Error(getErrorMessage(payload, `Failed to create skill (${response.status}).`));
|
||||
const nextSkill =
|
||||
payload && typeof payload === "object" && "skill" in payload && payload.skill && typeof payload.skill === "object"
|
||||
? (payload.skill as { id?: unknown })
|
||||
: null;
|
||||
const nextSkillId = typeof nextSkill?.id === "string" ? nextSkill.id : null;
|
||||
if (!nextSkillId) throw new Error("The skill was created, but no skill id was returned.");
|
||||
router.push(getSkillDetailRoute(orgSlug, nextSkillId));
|
||||
}
|
||||
router.refresh();
|
||||
} catch (nextError) {
|
||||
setSaveError(nextError instanceof Error ? nextError.message : "Could not save the skill.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileSelection(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
const text = await file.text();
|
||||
const draft = parseSkillDraft(text);
|
||||
setUploadedSkillText(text);
|
||||
setUploadedFileName(file.name);
|
||||
setName(draft.name || file.name.replace(/\.md$/i, ""));
|
||||
setDescription(draft.description);
|
||||
setDetails(draft.details || text);
|
||||
setMode("upload");
|
||||
}
|
||||
|
||||
if (busy && skillId && !skill) {
|
||||
return (
|
||||
<div className="mx-auto max-w-[900px] px-6 py-8 md:px-8">
|
||||
<div className="rounded-xl border border-gray-100 bg-white px-5 py-8 text-[13px] text-gray-400">
|
||||
Loading skill editor...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (skillId && !skill) {
|
||||
return (
|
||||
<div className="mx-auto max-w-[900px] px-6 py-8 md:px-8">
|
||||
<div className="rounded-xl border border-red-100 bg-red-50 px-5 py-3.5 text-[13px] text-red-600">
|
||||
{error ?? "That skill could not be found."}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[900px] px-6 py-8 md:px-8">
|
||||
|
||||
{/* Nav */}
|
||||
<div className="mb-6 flex items-center justify-between gap-4">
|
||||
<Link
|
||||
href={skillId ? getSkillDetailRoute(orgSlug, skillId) : getSkillHubsRoute(orgSlug)}
|
||||
className="inline-flex items-center gap-1.5 text-[13px] text-gray-400 transition hover:text-gray-700"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
</Link>
|
||||
|
||||
<DenButton loading={saving} onClick={() => void saveSkill()}>
|
||||
{skillId ? "Save Skill" : "Create Skill"}
|
||||
</DenButton>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{saveError ? (
|
||||
<div className="mb-5 rounded-xl border border-red-100 bg-red-50 px-4 py-3 text-[13px] text-red-600">
|
||||
{saveError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="rounded-2xl border border-gray-100 bg-white p-5 md:p-6">
|
||||
{/* Mode toggle */}
|
||||
<div className="mb-6 grid grid-cols-2 rounded-xl bg-gray-100/60 p-1 text-[13px] font-medium text-gray-500">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("manual")}
|
||||
className={`rounded-lg px-4 py-2 transition ${mode === "manual" ? "bg-white text-gray-900 shadow-sm" : "hover:text-gray-700"}`}
|
||||
>
|
||||
Manual Entry
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("upload")}
|
||||
className={`rounded-lg px-4 py-2 transition ${mode === "upload" ? "bg-white text-gray-900 shadow-sm" : "hover:text-gray-700"}`}
|
||||
>
|
||||
Upload SKILL.md
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Upload zone */}
|
||||
{mode === "upload" ? (
|
||||
<div className="mb-6 rounded-xl border border-dashed border-gray-200 bg-gray-50 px-6 py-7 text-center">
|
||||
<p className="text-[14px] font-medium text-gray-900">Upload a SKILL.md file</p>
|
||||
<p className="mt-1.5 text-[13px] text-gray-400">
|
||||
We'll keep the markdown source and prefill the fields for review.
|
||||
</p>
|
||||
<DenButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={Upload}
|
||||
className="mt-4"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{uploadedFileName ? `Replace ${uploadedFileName}` : "Choose file"}
|
||||
</DenButton>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".md,text/markdown"
|
||||
className="hidden"
|
||||
onChange={(event) => void handleFileSelection(event)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Two-column form layout */}
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||
|
||||
{/* Fields */}
|
||||
<div className="grid gap-5">
|
||||
<label className="grid gap-2">
|
||||
<span className="text-[13px] font-medium text-gray-600">Skill Name</span>
|
||||
<DenInput
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-2">
|
||||
<span className="text-[13px] font-medium text-gray-600">Visibility</span>
|
||||
<select
|
||||
value={visibility}
|
||||
onChange={(event) => setVisibility(event.target.value as SkillVisibility)}
|
||||
className="w-full rounded-lg border border-gray-200 bg-white px-4 py-2.5 text-[14px] text-gray-900 outline-none transition-all focus:border-gray-300 focus:ring-2 focus:ring-gray-900/5"
|
||||
>
|
||||
<option value="private">Private</option>
|
||||
<option value="org">Org</option>
|
||||
<option value="public">Public</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-2">
|
||||
<span className="text-[13px] font-medium text-gray-600">Short Description</span>
|
||||
<DenTextarea
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-2">
|
||||
<span className="text-[13px] font-medium text-gray-600">
|
||||
Detailed Instructions{" "}
|
||||
<span className="font-normal text-gray-400">(Markdown)</span>
|
||||
</span>
|
||||
<DenTextarea
|
||||
value={details}
|
||||
onChange={(event) => setDetails(event.target.value)}
|
||||
rows={16}
|
||||
className="font-mono text-[13px] leading-7"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Preview aside */}
|
||||
<aside className="grid gap-3 self-start xl:sticky xl:top-8">
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-4">
|
||||
<p className="mb-3 text-[11px] font-medium uppercase tracking-wide text-gray-400">
|
||||
Markdown Preview
|
||||
</p>
|
||||
<div className="max-h-[480px] overflow-auto rounded-lg border border-gray-100 bg-gray-50 px-4 py-4">
|
||||
<pre className="whitespace-pre-wrap font-mono text-[12px] leading-6 text-gray-600">
|
||||
{assembledPreview}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{uploadedFileName ? (
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-4">
|
||||
<p className="mb-1.5 text-[11px] font-medium uppercase tracking-wide text-gray-400">
|
||||
Uploaded Source
|
||||
</p>
|
||||
<p className="text-[13px] font-medium text-gray-900">{uploadedFileName}</p>
|
||||
<p className="mt-1 text-[12px] leading-relaxed text-gray-400">
|
||||
Original markdown is preserved in upload mode.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { getErrorMessage, requestJson } from "../../../../_lib/den-flow";
|
||||
|
||||
export type DenSkillShared = "org" | "public" | null;
|
||||
|
||||
export type DenSkill = {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
createdByOrgMembershipId: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
skillText: string;
|
||||
shared: DenSkillShared;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
canManage: boolean;
|
||||
};
|
||||
|
||||
export type DenSkillHubMemberAccess = {
|
||||
id: string;
|
||||
orgMembershipId: string;
|
||||
role: string;
|
||||
createdAt: string | null;
|
||||
user: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
image: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type DenSkillHubTeamAccess = {
|
||||
id: string;
|
||||
teamId: string;
|
||||
name: string;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export type DenSkillHub = {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
createdByOrgMembershipId: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
canManage: boolean;
|
||||
accessibleVia: {
|
||||
orgMembershipIds: string[];
|
||||
teamIds: string[];
|
||||
};
|
||||
skills: DenSkill[];
|
||||
access: {
|
||||
members: DenSkillHubMemberAccess[];
|
||||
teams: DenSkillHubTeamAccess[];
|
||||
};
|
||||
};
|
||||
|
||||
export type SkillComposerDraft = {
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
details: string;
|
||||
};
|
||||
|
||||
export const skillCategoryOptions = [
|
||||
"Engineering",
|
||||
"Workflow",
|
||||
"Marketing",
|
||||
"Operations",
|
||||
"Sales",
|
||||
"Support",
|
||||
"General",
|
||||
];
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
function asIsoString(value: unknown): string | null {
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
function asStringList(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
|
||||
}
|
||||
|
||||
function asSkill(value: unknown): DenSkill | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = asString(value.id);
|
||||
const organizationId = asString(value.organizationId);
|
||||
const createdByOrgMembershipId = asString(value.createdByOrgMembershipId);
|
||||
const title = asString(value.title);
|
||||
if (!id || !organizationId || !createdByOrgMembershipId || !title) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sharedValue = value.shared;
|
||||
const shared: DenSkillShared = sharedValue === "org" || sharedValue === "public" ? sharedValue : null;
|
||||
|
||||
return {
|
||||
id,
|
||||
organizationId,
|
||||
createdByOrgMembershipId,
|
||||
title,
|
||||
description: asString(value.description),
|
||||
skillText: asString(value.skillText) ?? "",
|
||||
shared,
|
||||
createdAt: asIsoString(value.createdAt),
|
||||
updatedAt: asIsoString(value.updatedAt),
|
||||
canManage: value.canManage === true,
|
||||
};
|
||||
}
|
||||
|
||||
function asSkillHubMemberAccess(value: unknown): DenSkillHubMemberAccess | null {
|
||||
if (!isRecord(value) || !isRecord(value.user)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = asString(value.id);
|
||||
const orgMembershipId = asString(value.orgMembershipId);
|
||||
const role = asString(value.role);
|
||||
const user = value.user;
|
||||
const userId = asString(user.id);
|
||||
const name = asString(user.name);
|
||||
const email = asString(user.email);
|
||||
if (!id || !orgMembershipId || !role || !userId || !name || !email) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
orgMembershipId,
|
||||
role,
|
||||
createdAt: asIsoString(value.createdAt),
|
||||
user: {
|
||||
id: userId,
|
||||
name,
|
||||
email,
|
||||
image: asString(user.image),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function asSkillHubTeamAccess(value: unknown): DenSkillHubTeamAccess | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = asString(value.id);
|
||||
const teamId = asString(value.teamId);
|
||||
const name = asString(value.name);
|
||||
if (!id || !teamId || !name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
teamId,
|
||||
name,
|
||||
createdAt: asIsoString(value.createdAt),
|
||||
updatedAt: asIsoString(value.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function asSkillHub(value: unknown): DenSkillHub | null {
|
||||
if (!isRecord(value) || !isRecord(value.access) || !isRecord(value.accessibleVia)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = asString(value.id);
|
||||
const organizationId = asString(value.organizationId);
|
||||
const createdByOrgMembershipId = asString(value.createdByOrgMembershipId);
|
||||
const name = asString(value.name);
|
||||
if (!id || !organizationId || !createdByOrgMembershipId || !name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const access = value.access;
|
||||
const accessibleVia = value.accessibleVia;
|
||||
|
||||
return {
|
||||
id,
|
||||
organizationId,
|
||||
createdByOrgMembershipId,
|
||||
name,
|
||||
description: asString(value.description),
|
||||
createdAt: asIsoString(value.createdAt),
|
||||
updatedAt: asIsoString(value.updatedAt),
|
||||
canManage: value.canManage === true,
|
||||
accessibleVia: {
|
||||
orgMembershipIds: asStringList(accessibleVia.orgMembershipIds),
|
||||
teamIds: asStringList(accessibleVia.teamIds),
|
||||
},
|
||||
skills: Array.isArray(value.skills) ? value.skills.map(asSkill).filter((entry): entry is DenSkill => entry !== null) : [],
|
||||
access: {
|
||||
members: Array.isArray(access.members)
|
||||
? access.members.map(asSkillHubMemberAccess).filter((entry): entry is DenSkillHubMemberAccess => entry !== null)
|
||||
: [],
|
||||
teams: Array.isArray(access.teams)
|
||||
? access.teams.map(asSkillHubTeamAccess).filter((entry): entry is DenSkillHubTeamAccess => entry !== null)
|
||||
: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSkillCategory(skillText: string): string | null {
|
||||
const match = skillText.match(/^category\s*:\s*(.+)$/im);
|
||||
return match && match[1] ? match[1].trim() : null;
|
||||
}
|
||||
|
||||
export function parseSkillDraft(skillText: string, fallback?: { name?: string | null; description?: string | null }): SkillComposerDraft {
|
||||
const lines = skillText.split(/\r?\n/g);
|
||||
const nonEmptyIndexes = lines.reduce<number[]>((indexes, line, index) => {
|
||||
if (line.trim()) {
|
||||
indexes.push(index);
|
||||
}
|
||||
return indexes;
|
||||
}, []);
|
||||
|
||||
const titleIndex = nonEmptyIndexes[0] ?? -1;
|
||||
const descriptionIndex = nonEmptyIndexes[1] ?? -1;
|
||||
const categoryIndex = nonEmptyIndexes.find((index) => /^category\s*:/i.test(lines[index]?.trim() ?? "")) ?? -1;
|
||||
|
||||
const bodyStartIndex = categoryIndex >= 0
|
||||
? categoryIndex + 1
|
||||
: descriptionIndex >= 0
|
||||
? descriptionIndex + 1
|
||||
: titleIndex >= 0
|
||||
? titleIndex + 1
|
||||
: 0;
|
||||
|
||||
const titleLine = titleIndex >= 0 ? lines[titleIndex] : fallback?.name ?? "";
|
||||
const descriptionLine = descriptionIndex >= 0 ? lines[descriptionIndex] : fallback?.description ?? "";
|
||||
|
||||
return {
|
||||
name: cleanupSkillMetadataLine(titleLine) || fallback?.name || "",
|
||||
description: cleanupSkillMetadataLine(descriptionLine) || fallback?.description || "",
|
||||
category: categoryIndex >= 0 ? lines[categoryIndex].replace(/^category\s*:/i, "").trim() : parseSkillCategory(skillText) ?? "General",
|
||||
details: lines.slice(bodyStartIndex).join("\n").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export function getSkillBodyText(skillText: string, fallback?: { name?: string | null; description?: string | null }) {
|
||||
const draft = parseSkillDraft(skillText, fallback);
|
||||
return draft.details || skillText;
|
||||
}
|
||||
|
||||
export function getSkillBodyPreview(skillText: string, fallback?: { name?: string | null; description?: string | null }) {
|
||||
const body = getSkillBodyText(skillText, fallback)
|
||||
.replace(/^#{1,6}\s+/gm, "")
|
||||
.replace(/^[-*+]\s+/gm, "")
|
||||
.trim();
|
||||
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstParagraph = body
|
||||
.split(/\n\s*\n/g)
|
||||
.map((section) => section.trim())
|
||||
.find(Boolean);
|
||||
|
||||
return firstParagraph ?? null;
|
||||
}
|
||||
|
||||
function cleanupSkillMetadataLine(value: string): string {
|
||||
return value
|
||||
.replace(/^#{1,6}\s+/, "")
|
||||
.replace(/^[-*+]\s+/, "")
|
||||
.replace(/^title\s*:\s*/i, "")
|
||||
.replace(/^description\s*:\s*/i, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function buildSkillText(input: SkillComposerDraft): string {
|
||||
const sections = [`# ${input.name.trim()}`];
|
||||
|
||||
if (input.description.trim()) {
|
||||
sections.push(input.description.trim());
|
||||
}
|
||||
|
||||
if (input.category.trim()) {
|
||||
sections.push(`Category: ${input.category.trim()}`);
|
||||
}
|
||||
|
||||
if (input.details.trim()) {
|
||||
sections.push(input.details.trim());
|
||||
}
|
||||
|
||||
return `${sections.join("\n\n")}\n`;
|
||||
}
|
||||
|
||||
export function getSkillVisibilityLabel(shared: DenSkillShared): string {
|
||||
if (shared === "org") {
|
||||
return "Org";
|
||||
}
|
||||
if (shared === "public") {
|
||||
return "Public";
|
||||
}
|
||||
return "Private";
|
||||
}
|
||||
|
||||
export function formatSkillTimestamp(value: string | null) {
|
||||
if (!value) {
|
||||
return "Recently updated";
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return "Recently updated";
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function getHubAccent(seed: string) {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < seed.length; index += 1) {
|
||||
hash = (hash * 33 + seed.charCodeAt(index)) % 360;
|
||||
}
|
||||
|
||||
const hue = hash;
|
||||
const gradient = `radial-gradient(circle at 18% 18%, hsla(${(hue + 18) % 360} 92% 92% / 0.95) 0%, transparent 38%), linear-gradient(135deg, hsl(${hue} 85% 78%) 0%, hsl(${(hue + 32) % 360} 92% 86%) 48%, hsl(${(hue + 86) % 360} 78% 86%) 100%)`;
|
||||
|
||||
return {
|
||||
gradient,
|
||||
grain: `radial-gradient(circle at 20% 20%, rgba(255,255,255,0.65) 0%, rgba(255,255,255,0) 45%), radial-gradient(circle at 80% 30%, rgba(255,255,255,0.48) 0%, rgba(255,255,255,0) 35%), radial-gradient(circle at 55% 78%, rgba(15,23,42,0.08) 0%, rgba(15,23,42,0) 42%)`,
|
||||
};
|
||||
}
|
||||
|
||||
export function useOrgSkillLibrary(orgId: string | null) {
|
||||
const [skills, setSkills] = useState<DenSkill[]>([]);
|
||||
const [skillHubs, setSkillHubs] = useState<DenSkillHub[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function loadLibrary() {
|
||||
if (!orgId) {
|
||||
setSkills([]);
|
||||
setSkillHubs([]);
|
||||
setError("Organization not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [skillsResult, skillHubsResult] = await Promise.all([
|
||||
requestJson(`/v1/orgs/${encodeURIComponent(orgId)}/skills`, { method: "GET" }, 12000),
|
||||
requestJson(`/v1/orgs/${encodeURIComponent(orgId)}/skill-hubs`, { method: "GET" }, 12000),
|
||||
]);
|
||||
|
||||
if (!skillsResult.response.ok) {
|
||||
throw new Error(getErrorMessage(skillsResult.payload, `Failed to load skills (${skillsResult.response.status}).`));
|
||||
}
|
||||
|
||||
if (!skillHubsResult.response.ok) {
|
||||
throw new Error(getErrorMessage(skillHubsResult.payload, `Failed to load skill hubs (${skillHubsResult.response.status}).`));
|
||||
}
|
||||
|
||||
const nextSkills = isRecord(skillsResult.payload) && Array.isArray(skillsResult.payload.skills)
|
||||
? skillsResult.payload.skills.map(asSkill).filter((entry): entry is DenSkill => entry !== null)
|
||||
: [];
|
||||
const nextSkillHubs = isRecord(skillHubsResult.payload) && Array.isArray(skillHubsResult.payload.skillHubs)
|
||||
? skillHubsResult.payload.skillHubs.map(asSkillHub).filter((entry): entry is DenSkillHub => entry !== null)
|
||||
: [];
|
||||
|
||||
setSkills(nextSkills);
|
||||
setSkillHubs(nextSkillHubs);
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : "Failed to load the skill library.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadLibrary();
|
||||
}, [orgId]);
|
||||
|
||||
return {
|
||||
skills,
|
||||
skillHubs,
|
||||
busy,
|
||||
error,
|
||||
reloadLibrary: loadLibrary,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import { ArrowLeft, BookOpen, Pencil } from "lucide-react";
|
||||
import { PaperMeshGradient } from "@openwork/ui/react";
|
||||
import { buttonVariants } from "../../../../_components/ui/button";
|
||||
import {
|
||||
getEditSkillHubRoute,
|
||||
getSkillDetailRoute,
|
||||
getSkillHubsRoute,
|
||||
} from "../../../../_lib/den-org";
|
||||
import { useOrgDashboard } from "../_providers/org-dashboard-provider";
|
||||
import {
|
||||
formatSkillTimestamp,
|
||||
getSkillVisibilityLabel,
|
||||
parseSkillCategory,
|
||||
useOrgSkillLibrary,
|
||||
} from "./skill-hub-data";
|
||||
|
||||
export function SkillHubDetailScreen({ skillHubId }: { skillHubId: string }) {
|
||||
const { orgId, orgSlug } = useOrgDashboard();
|
||||
const { skillHubs, busy, error } = useOrgSkillLibrary(orgId);
|
||||
const skillHub = useMemo(
|
||||
() => skillHubs.find((entry) => entry.id === skillHubId) ?? null,
|
||||
[skillHubId, skillHubs],
|
||||
);
|
||||
|
||||
if (busy && !skillHub) {
|
||||
return (
|
||||
<div className="mx-auto max-w-[900px] px-6 py-8 md:px-8">
|
||||
<div className="rounded-xl border border-gray-100 bg-white px-5 py-8 text-[13px] text-gray-400">
|
||||
Loading hub details...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!skillHub) {
|
||||
return (
|
||||
<div className="mx-auto max-w-[900px] px-6 py-8 md:px-8">
|
||||
<div className="rounded-xl border border-red-100 bg-red-50 px-5 py-3.5 text-[13px] text-red-600">
|
||||
{error ?? "That hub could not be found."}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[900px] px-6 py-8 md:px-8">
|
||||
|
||||
{/* Nav */}
|
||||
<div className="mb-6 flex items-center justify-between gap-4">
|
||||
<Link
|
||||
href={getSkillHubsRoute(orgSlug)}
|
||||
className="inline-flex items-center gap-1.5 text-[13px] text-gray-400 transition hover:text-gray-700"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
</Link>
|
||||
|
||||
{skillHub.canManage ? (
|
||||
<Link
|
||||
href={getEditSkillHubRoute(orgSlug, skillHub.id)}
|
||||
className={buttonVariants({ variant: "secondary", size: "sm" })}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Edit Hub
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_240px]">
|
||||
|
||||
{/* ── Main card ── */}
|
||||
<section className="overflow-hidden rounded-2xl border border-gray-100 bg-white">
|
||||
|
||||
{/* Gradient header — seeded by hub id, matches list card */}
|
||||
<div className="relative h-40 overflow-hidden border-b border-gray-100">
|
||||
<div className="absolute inset-0">
|
||||
<PaperMeshGradient seed={skillHub.id} speed={0} />
|
||||
</div>
|
||||
<div className="absolute bottom-[-20px] left-6 flex h-14 w-14 items-center justify-center rounded-[18px] border border-white/60 bg-white shadow-[0_12px_24px_-12px_rgba(15,23,42,0.3)]">
|
||||
<BookOpen className="h-6 w-6 text-gray-700" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 pb-6 pt-10">
|
||||
{/* Title + description + last updated */}
|
||||
<h1 className="text-[18px] font-semibold text-gray-900">{skillHub.name}</h1>
|
||||
{skillHub.description ? (
|
||||
<p className="mt-1.5 text-[13px] leading-relaxed text-gray-400">
|
||||
{skillHub.description}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-2 text-[12px] text-gray-300">
|
||||
Updated {formatSkillTimestamp(skillHub.updatedAt)}
|
||||
</p>
|
||||
|
||||
{/* Included skills */}
|
||||
<div className="mt-6 border-t border-gray-100 pt-5">
|
||||
<p className="mb-3 text-[11px] font-medium uppercase tracking-wide text-gray-400">
|
||||
{skillHub.skills.length === 0
|
||||
? "No skills yet"
|
||||
: `${skillHub.skills.length} ${skillHub.skills.length === 1 ? "Skill" : "Skills"}`}
|
||||
</p>
|
||||
|
||||
{skillHub.skills.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-gray-100 px-5 py-6 text-[13px] text-gray-400">
|
||||
This hub does not include any skills yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-1.5">
|
||||
{skillHub.skills.map((skill) => (
|
||||
<Link
|
||||
key={skill.id}
|
||||
href={getSkillDetailRoute(orgSlug, skill.id)}
|
||||
className="flex items-center justify-between gap-4 rounded-xl border border-gray-100 px-4 py-3 transition hover:border-gray-200 hover:bg-gray-50/60"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-[13px] font-medium text-gray-900">
|
||||
{skill.title}
|
||||
</p>
|
||||
{skill.description ? (
|
||||
<p className="mt-0.5 truncate text-[12px] text-gray-400">
|
||||
{skill.description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="shrink-0 rounded-full bg-gray-100 px-2.5 py-0.5 text-[11px] text-gray-400">
|
||||
{parseSkillCategory(skill.skillText) ?? getSkillVisibilityLabel(skill.shared)}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Sidebar ── */}
|
||||
<aside className="grid gap-3 self-start">
|
||||
|
||||
{/* Teams */}
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-4">
|
||||
<p className="mb-3 text-[11px] font-medium uppercase tracking-wide text-gray-400">
|
||||
Teams
|
||||
</p>
|
||||
{skillHub.access.teams.length === 0 ? (
|
||||
<span className="text-[13px] text-gray-400">No teams assigned.</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{skillHub.access.teams.map((team) => (
|
||||
<span
|
||||
key={team.teamId}
|
||||
className="rounded-full bg-gray-100 px-3 py-1 text-[12px] text-gray-500"
|
||||
>
|
||||
{team.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Direct access — only show when populated */}
|
||||
{skillHub.access.members.length > 0 ? (
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-4">
|
||||
<p className="mb-3 text-[11px] font-medium uppercase tracking-wide text-gray-400">
|
||||
Direct Access
|
||||
</p>
|
||||
<div className="divide-y divide-gray-100">
|
||||
{skillHub.access.members.map((member) => (
|
||||
<div key={member.id} className="py-2.5 first:pt-0 last:pb-0">
|
||||
<p className="text-[13px] font-medium text-gray-900">
|
||||
{member.user.name}
|
||||
</p>
|
||||
<p className="text-[12px] text-gray-400">{member.user.email}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ArrowLeft, BookOpen, CheckCircle2, Circle, Search } from "lucide-react";
|
||||
import { DenButton } from "../../../../_components/ui/button";
|
||||
import { DenInput } from "../../../../_components/ui/input";
|
||||
import { DenTextarea } from "../../../../_components/ui/textarea";
|
||||
import { getErrorMessage, requestJson } from "../../../../_lib/den-flow";
|
||||
import {
|
||||
getOrgAccessFlags,
|
||||
getSkillHubsRoute,
|
||||
getSkillHubRoute,
|
||||
} from "../../../../_lib/den-org";
|
||||
import { useOrgDashboard } from "../_providers/org-dashboard-provider";
|
||||
import {
|
||||
getSkillVisibilityLabel,
|
||||
parseSkillCategory,
|
||||
useOrgSkillLibrary,
|
||||
} from "./skill-hub-data";
|
||||
|
||||
export function SkillHubEditorScreen({ skillHubId }: { skillHubId?: string }) {
|
||||
const router = useRouter();
|
||||
const { orgId, orgSlug, orgContext } = useOrgDashboard();
|
||||
const { skills, skillHubs, busy, error, reloadLibrary } = useOrgSkillLibrary(orgId);
|
||||
const skillHub = useMemo(
|
||||
() => (skillHubId ? skillHubs.find((entry) => entry.id === skillHubId) ?? null : null),
|
||||
[skillHubId, skillHubs],
|
||||
);
|
||||
|
||||
const access = useMemo(
|
||||
() => getOrgAccessFlags(orgContext?.currentMember.role ?? "member", orgContext?.currentMember.isOwner ?? false),
|
||||
[orgContext?.currentMember.isOwner, orgContext?.currentMember.role],
|
||||
);
|
||||
|
||||
const canManage = skillHubId ? skillHub?.canManage === true : true;
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [selectedTeamIds, setSelectedTeamIds] = useState<string[]>([]);
|
||||
const [selectedSkillIds, setSelectedSkillIds] = useState<string[]>([]);
|
||||
const [skillQuery, setSkillQuery] = useState("");
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (skillHubId) {
|
||||
if (!skillHub) {
|
||||
return;
|
||||
}
|
||||
|
||||
setName(skillHub.name);
|
||||
setDescription(skillHub.description ?? "");
|
||||
setSelectedTeamIds(skillHub.access.teams.map((entry) => entry.teamId));
|
||||
setSelectedSkillIds(skillHub.skills.map((entry) => entry.id));
|
||||
return;
|
||||
}
|
||||
|
||||
setName("");
|
||||
setDescription("");
|
||||
setSelectedTeamIds([]);
|
||||
setSelectedSkillIds([]);
|
||||
}, [skillHub, skillHubId]);
|
||||
|
||||
const filteredSkills = useMemo(() => {
|
||||
const normalizedQuery = skillQuery.trim().toLowerCase();
|
||||
if (!normalizedQuery) {
|
||||
return skills;
|
||||
}
|
||||
|
||||
return skills.filter((skill) => {
|
||||
const category = parseSkillCategory(skill.skillText) ?? "";
|
||||
return (
|
||||
skill.title.toLowerCase().includes(normalizedQuery) ||
|
||||
(skill.description ?? "").toLowerCase().includes(normalizedQuery) ||
|
||||
category.toLowerCase().includes(normalizedQuery)
|
||||
);
|
||||
});
|
||||
}, [skillQuery, skills]);
|
||||
|
||||
const currentTeamAccessById = useMemo(
|
||||
() => new Map((skillHub?.access.teams ?? []).map((entry) => [entry.teamId, entry.id])),
|
||||
[skillHub?.access.teams],
|
||||
);
|
||||
|
||||
const currentSkillIds = useMemo(() => new Set(skillHub?.skills.map((entry) => entry.id) ?? []), [skillHub?.skills]);
|
||||
|
||||
async function saveHub() {
|
||||
if (!orgId) {
|
||||
setSaveError("Organization not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!name.trim()) {
|
||||
setSaveError("Enter a hub name.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
let nextSkillHubId = skillHubId ?? null;
|
||||
|
||||
if (!nextSkillHubId) {
|
||||
const { response, payload } = await requestJson(
|
||||
`/v1/orgs/${encodeURIComponent(orgId)}/skill-hubs`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: name.trim(),
|
||||
description: description.trim() || null,
|
||||
}),
|
||||
},
|
||||
12000,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(getErrorMessage(payload, `Failed to create hub (${response.status}).`));
|
||||
}
|
||||
|
||||
const nextHub = payload && typeof payload === "object" && payload && "skillHub" in payload && payload.skillHub && typeof payload.skillHub === "object"
|
||||
? payload.skillHub as { id?: unknown }
|
||||
: null;
|
||||
nextSkillHubId = typeof nextHub?.id === "string" ? nextHub.id : null;
|
||||
if (!nextSkillHubId) {
|
||||
throw new Error("The hub was created, but no hub id was returned.");
|
||||
}
|
||||
} else if (skillHub && (skillHub.name !== name.trim() || (skillHub.description ?? "") !== description.trim())) {
|
||||
const { response, payload } = await requestJson(
|
||||
`/v1/orgs/${encodeURIComponent(orgId)}/skill-hubs/${encodeURIComponent(nextSkillHubId)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
name: name.trim(),
|
||||
description: description.trim() || null,
|
||||
}),
|
||||
},
|
||||
12000,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(getErrorMessage(payload, `Failed to update hub (${response.status}).`));
|
||||
}
|
||||
}
|
||||
|
||||
const teamIdsToAdd = selectedTeamIds.filter((teamId) => !currentTeamAccessById.has(teamId));
|
||||
const teamAccessIdsToRemove = [...currentTeamAccessById.entries()]
|
||||
.filter(([teamId]) => !selectedTeamIds.includes(teamId))
|
||||
.map(([, accessId]) => accessId);
|
||||
const skillIdsToAdd = selectedSkillIds.filter((entry) => !currentSkillIds.has(entry));
|
||||
const skillIdsToRemove = [...currentSkillIds].filter((entry) => !selectedSkillIds.includes(entry));
|
||||
|
||||
await Promise.all(teamIdsToAdd.map(async (teamId) => {
|
||||
const { response, payload } = await requestJson(
|
||||
`/v1/orgs/${encodeURIComponent(orgId)}/skill-hubs/${encodeURIComponent(nextSkillHubId)}/access`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ teamId }),
|
||||
},
|
||||
12000,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(getErrorMessage(payload, `Failed to grant team access (${response.status}).`));
|
||||
}
|
||||
}));
|
||||
|
||||
await Promise.all(teamAccessIdsToRemove.map(async (accessId) => {
|
||||
const { response, payload } = await requestJson(
|
||||
`/v1/orgs/${encodeURIComponent(orgId)}/skill-hubs/${encodeURIComponent(nextSkillHubId)}/access/${encodeURIComponent(accessId)}`,
|
||||
{ method: "DELETE" },
|
||||
12000,
|
||||
);
|
||||
|
||||
if (response.status !== 204 && !response.ok) {
|
||||
throw new Error(getErrorMessage(payload, `Failed to remove team access (${response.status}).`));
|
||||
}
|
||||
}));
|
||||
|
||||
await Promise.all(skillIdsToAdd.map(async (entry) => {
|
||||
const { response, payload } = await requestJson(
|
||||
`/v1/orgs/${encodeURIComponent(orgId)}/skill-hubs/${encodeURIComponent(nextSkillHubId)}/skills`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ skillId: entry }),
|
||||
},
|
||||
12000,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(getErrorMessage(payload, `Failed to add a skill (${response.status}).`));
|
||||
}
|
||||
}));
|
||||
|
||||
await Promise.all(skillIdsToRemove.map(async (entry) => {
|
||||
const { response, payload } = await requestJson(
|
||||
`/v1/orgs/${encodeURIComponent(orgId)}/skill-hubs/${encodeURIComponent(nextSkillHubId)}/skills/${encodeURIComponent(entry)}`,
|
||||
{ method: "DELETE" },
|
||||
12000,
|
||||
);
|
||||
|
||||
if (response.status !== 204 && !response.ok) {
|
||||
throw new Error(getErrorMessage(payload, `Failed to remove a skill (${response.status}).`));
|
||||
}
|
||||
}));
|
||||
|
||||
await reloadLibrary();
|
||||
router.push(skillHubId ? getSkillHubRoute(orgSlug, nextSkillHubId) : getSkillHubsRoute(orgSlug));
|
||||
router.refresh();
|
||||
} catch (nextError) {
|
||||
setSaveError(nextError instanceof Error ? nextError.message : "Could not save the hub.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (busy && skillHubId && !skillHub) {
|
||||
return (
|
||||
<div className="mx-auto max-w-[1180px] px-6 py-8 md:px-8">
|
||||
<div className="rounded-[28px] border border-gray-200 bg-white px-6 py-10 text-[15px] text-gray-500">
|
||||
Loading hub details...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (skillHubId && !skillHub) {
|
||||
return (
|
||||
<div className="mx-auto max-w-[1180px] px-6 py-8 md:px-8">
|
||||
<div className="rounded-[28px] border border-red-200 bg-red-50 px-6 py-4 text-[15px] text-red-700">
|
||||
{error ?? "That hub could not be found."}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1180px] px-6 py-8 md:px-8">
|
||||
<div className="mb-8 flex flex-col gap-3">
|
||||
<p className="text-[12px] font-semibold uppercase tracking-[0.18em] text-gray-400">
|
||||
{skillHubId ? "Skill hub editor" : "Create a hub"}
|
||||
</p>
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div>
|
||||
<h1 className="text-[34px] font-semibold tracking-[-0.07em] text-gray-950">
|
||||
{skillHubId ? skillHub?.name ?? "Hub details" : "Create a new skill hub"}
|
||||
</h1>
|
||||
<p className="mt-3 max-w-[700px] text-[16px] leading-8 text-gray-500">
|
||||
Shape who can access this collection, then pick the exact skills each team should inherit.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 text-[13px] font-medium text-gray-600">
|
||||
<span className="rounded-full border border-gray-200 bg-white px-4 py-2">
|
||||
{selectedTeamIds.length} {selectedTeamIds.length === 1 ? "team selected" : "teams selected"}
|
||||
</span>
|
||||
<span className="rounded-full border border-gray-200 bg-white px-4 py-2">
|
||||
{selectedSkillIds.length} {selectedSkillIds.length === 1 ? "skill selected" : "skills selected"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 flex items-center justify-between gap-4">
|
||||
<Link
|
||||
href={skillHubId ? getSkillHubRoute(orgSlug, skillHubId) : getSkillHubsRoute(orgSlug)}
|
||||
className="inline-flex items-center gap-2 text-[15px] font-medium text-gray-500 transition hover:text-gray-900"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
Back
|
||||
</Link>
|
||||
|
||||
{canManage ? (
|
||||
<DenButton loading={saving} onClick={() => void saveHub()}>
|
||||
{skillHubId ? "Save Hub" : "Create Hub"}
|
||||
</DenButton>
|
||||
) : (
|
||||
<span className="rounded-full border border-gray-200 bg-white px-4 py-2 text-[13px] font-medium text-gray-500">
|
||||
Read only
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{saveError ? (
|
||||
<div className="mb-6 rounded-[28px] border border-red-200 bg-red-50 px-6 py-4 text-[14px] text-red-700">
|
||||
{saveError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="mb-8 rounded-[36px] border border-gray-200 bg-white p-8 shadow-[0_18px_48px_-34px_rgba(15,23,42,0.24)]">
|
||||
<h2 className="mb-8 text-[24px] font-semibold tracking-[-0.05em] text-gray-950">Hub Details</h2>
|
||||
<div className="grid gap-6">
|
||||
<label className="grid gap-3">
|
||||
<span className="text-[14px] font-medium text-gray-700">Hub Name</span>
|
||||
<DenInput
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
disabled={!canManage}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-3">
|
||||
<span className="text-[14px] font-medium text-gray-700">Description</span>
|
||||
<DenTextarea
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
disabled={!canManage}
|
||||
rows={4}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mb-8 rounded-[36px] border border-gray-200 bg-white p-8 shadow-[0_18px_48px_-34px_rgba(15,23,42,0.24)]">
|
||||
<h2 className="text-[24px] font-semibold tracking-[-0.05em] text-gray-950">Assigned Teams</h2>
|
||||
<p className="mt-2 text-[15px] text-gray-500">Select which teams have access to this hub.</p>
|
||||
{skillHub?.access.members.length ? (
|
||||
<p className="mt-3 text-[13px] text-gray-400">
|
||||
{skillHub.access.members.length} direct member grant{skillHub.access.members.length === 1 ? "" : "s"} already exist and will stay in place.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{orgContext?.teams.length ? (
|
||||
<div className="mt-8 grid gap-4 md:grid-cols-2">
|
||||
{orgContext.teams.map((team) => {
|
||||
const selected = selectedTeamIds.includes(team.id);
|
||||
return (
|
||||
<button
|
||||
key={team.id}
|
||||
type="button"
|
||||
disabled={!canManage}
|
||||
onClick={() => {
|
||||
if (!canManage) {
|
||||
return;
|
||||
}
|
||||
setSelectedTeamIds((current) =>
|
||||
current.includes(team.id)
|
||||
? current.filter((entry) => entry !== team.id)
|
||||
: [...current, team.id],
|
||||
);
|
||||
}}
|
||||
className={`flex min-h-[84px] items-center gap-4 rounded-[24px] border px-5 py-4 text-left transition ${
|
||||
selected
|
||||
? "border-[#0f172a] bg-[#0f172a] text-white"
|
||||
: "border-gray-200 bg-white text-gray-700 hover:border-gray-300"
|
||||
} ${!canManage ? "cursor-default" : "cursor-pointer"}`}
|
||||
>
|
||||
{selected ? <CheckCircle2 className="h-7 w-7 shrink-0" /> : <Circle className="h-7 w-7 shrink-0 text-gray-300" />}
|
||||
<div>
|
||||
<p className="text-[17px] font-medium tracking-[-0.03em]">{team.name}</p>
|
||||
<p className={`mt-1 text-[13px] ${selected ? "text-white/70" : "text-gray-400"}`}>
|
||||
{team.memberIds.length} {team.memberIds.length === 1 ? "member" : "members"}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-8 rounded-[24px] border border-dashed border-gray-200 bg-gray-50 px-5 py-6 text-[15px] text-gray-500">
|
||||
Create teams from the Members page before assigning hub access.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-[36px] border border-gray-200 bg-white p-8 shadow-[0_18px_48px_-34px_rgba(15,23,42,0.24)]">
|
||||
<div className="mb-6 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h2 className="text-[24px] font-semibold tracking-[-0.05em] text-gray-950">Hub Skills</h2>
|
||||
<p className="mt-2 text-[15px] text-gray-500">Select the skills to include in this hub.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<DenInput
|
||||
type="search"
|
||||
icon={Search}
|
||||
value={skillQuery}
|
||||
onChange={(event) => setSkillQuery(event.target.value)}
|
||||
placeholder="Search skills..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[560px] overflow-y-auto border-t border-gray-100 pt-6">
|
||||
{filteredSkills.length === 0 ? (
|
||||
<div className="rounded-[24px] border border-dashed border-gray-200 bg-gray-50 px-5 py-6 text-[15px] text-gray-500">
|
||||
No skills match that search.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{filteredSkills.map((skill) => {
|
||||
const selected = selectedSkillIds.includes(skill.id);
|
||||
const isPrivateRestricted = skill.shared === null && !skill.canManage && !access.isAdmin;
|
||||
return (
|
||||
<button
|
||||
key={skill.id}
|
||||
type="button"
|
||||
disabled={!canManage || isPrivateRestricted}
|
||||
onClick={() => {
|
||||
if (!canManage || isPrivateRestricted) {
|
||||
return;
|
||||
}
|
||||
setSelectedSkillIds((current) =>
|
||||
current.includes(skill.id)
|
||||
? current.filter((entry) => entry !== skill.id)
|
||||
: [...current, skill.id],
|
||||
);
|
||||
}}
|
||||
className={`flex items-start gap-4 rounded-[24px] border px-5 py-5 text-left transition ${
|
||||
selected
|
||||
? "border-[#0f172a] bg-[#f8fafc]"
|
||||
: "border-gray-200 bg-white hover:border-gray-300"
|
||||
} ${isPrivateRestricted ? "cursor-not-allowed opacity-60" : !canManage ? "cursor-default" : "cursor-pointer"}`}
|
||||
>
|
||||
{selected ? <CheckCircle2 className="mt-0.5 h-7 w-7 shrink-0 text-[#0f172a]" /> : <Circle className="mt-0.5 h-7 w-7 shrink-0 text-gray-300" />}
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-[18px] font-semibold tracking-[-0.03em] text-gray-950">{skill.title}</span>
|
||||
<span className="rounded-full bg-gray-100 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-gray-500">
|
||||
{parseSkillCategory(skill.skillText) ?? getSkillVisibilityLabel(skill.shared)}
|
||||
</span>
|
||||
<span className="rounded-full bg-gray-100 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-gray-500">
|
||||
{getSkillVisibilityLabel(skill.shared)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-[15px] leading-7 text-gray-500">
|
||||
{skill.description || "No description yet."}
|
||||
</p>
|
||||
{isPrivateRestricted ? (
|
||||
<p className="mt-3 text-[13px] text-amber-600">
|
||||
Private skills can only be added by their creator or an org admin.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { BookOpen, FileText, Plus, Search } from "lucide-react";
|
||||
import { PaperMeshGradient } from "@openwork/ui/react";
|
||||
import { UnderlineTabs } from "../../../../_components/ui/tabs";
|
||||
import { DashboardPageTemplate } from "../../../../_components/ui/dashboard-page-template";
|
||||
import { DenButton, buttonVariants } from "../../../../_components/ui/button";
|
||||
import { DenInput } from "../../../../_components/ui/input";
|
||||
import {
|
||||
getNewSkillHubRoute,
|
||||
getNewSkillRoute,
|
||||
getSkillDetailRoute,
|
||||
getSkillHubRoute,
|
||||
} from "../../../../_lib/den-org";
|
||||
import { useOrgDashboard } from "../_providers/org-dashboard-provider";
|
||||
import {
|
||||
formatSkillTimestamp,
|
||||
getSkillVisibilityLabel,
|
||||
useOrgSkillLibrary,
|
||||
} from "./skill-hub-data";
|
||||
|
||||
type SkillLibraryView = "hubs" | "skills";
|
||||
|
||||
const SKILL_LIBRARY_TABS = [
|
||||
{ value: "hubs" as const, label: "Hubs", icon: BookOpen },
|
||||
{ value: "skills" as const, label: "All Skills", icon: FileText },
|
||||
];
|
||||
|
||||
export function SkillHubsScreen() {
|
||||
const { activeOrg, orgId, orgSlug, orgContext } = useOrgDashboard();
|
||||
const { skills, skillHubs, busy, error } = useOrgSkillLibrary(orgId);
|
||||
const [activeView, setActiveView] = useState<SkillLibraryView>("hubs");
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const filteredHubs = useMemo(() => {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
if (!normalizedQuery) {
|
||||
return skillHubs;
|
||||
}
|
||||
|
||||
return skillHubs.filter((skillHub) => {
|
||||
return (
|
||||
skillHub.name.toLowerCase().includes(normalizedQuery) ||
|
||||
(skillHub.description ?? "").toLowerCase().includes(normalizedQuery) ||
|
||||
skillHub.access.teams.some((team) => team.name.toLowerCase().includes(normalizedQuery))
|
||||
);
|
||||
});
|
||||
}, [query, skillHubs]);
|
||||
|
||||
const filteredSkills = useMemo(() => {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
if (!normalizedQuery) {
|
||||
return skills;
|
||||
}
|
||||
|
||||
return skills.filter((skill) =>
|
||||
skill.title.toLowerCase().includes(normalizedQuery) ||
|
||||
(skill.description ?? "").toLowerCase().includes(normalizedQuery),
|
||||
);
|
||||
}, [query, skills]);
|
||||
|
||||
return (
|
||||
<DashboardPageTemplate
|
||||
icon={BookOpen}
|
||||
badgeLabel="New"
|
||||
title="Skill Hubs"
|
||||
description="Curate shared skill libraries for each team, then publish reusable skills your whole organization can discover."
|
||||
colors={["#FFF0F3", "#881337", "#F43F5E", "#FDA4AF"]}
|
||||
>
|
||||
<div className="mb-8 flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div className="flex flex-col gap-4">
|
||||
<UnderlineTabs tabs={SKILL_LIBRARY_TABS} activeTab={activeView} onChange={setActiveView} />
|
||||
<div>
|
||||
<DenInput
|
||||
type="search"
|
||||
icon={Search}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={activeView === "hubs" ? "Search hubs..." : "Search skills..."}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={activeView === "hubs" ? getNewSkillHubRoute(orgSlug) : getNewSkillRoute(orgSlug)}
|
||||
className={buttonVariants({ variant: "primary" })}
|
||||
>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
{activeView === "hubs" ? "Create Hub" : "Add Skill"}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="mb-6 rounded-[24px] border border-red-200 bg-red-50 px-5 py-4 text-[14px] text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{busy ? (
|
||||
<div className="rounded-[28px] border border-gray-200 bg-white px-6 py-10 text-[15px] text-gray-500">
|
||||
Loading your skill library...
|
||||
</div>
|
||||
) : activeView === "hubs" ? (
|
||||
filteredHubs.length === 0 ? (
|
||||
<div className="rounded-[32px] border border-dashed border-gray-200 bg-white px-6 py-12 text-center">
|
||||
<p className="text-[16px] font-medium tracking-[-0.03em] text-gray-900">
|
||||
{skillHubs.length === 0 ? "No skill hubs yet." : "No skill hubs match that search yet."}
|
||||
</p>
|
||||
<p className="mx-auto mt-3 max-w-[520px] text-[15px] leading-8 text-gray-500">
|
||||
{skillHubs.length === 0
|
||||
? "Create your first hub to organize shared skills by team and control who can access each collection."
|
||||
: "Try a different search term, or switch to All Skills to browse the individual skills already available in this org."}
|
||||
</p>
|
||||
{skillHubs.length === 0 && skills.length > 0 ? (
|
||||
<DenButton
|
||||
variant="secondary"
|
||||
className="mt-6"
|
||||
onClick={() => setActiveView("skills")}
|
||||
>
|
||||
Browse all skills
|
||||
</DenButton>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-2 2xl:grid-cols-3">
|
||||
{filteredHubs.map((skillHub) => (
|
||||
<Link
|
||||
key={skillHub.id}
|
||||
href={getSkillHubRoute(orgSlug, skillHub.id)}
|
||||
className="block overflow-hidden rounded-2xl border border-gray-100 bg-white transition hover:-translate-y-0.5 hover:border-gray-200 hover:shadow-[0_8px_24px_-8px_rgba(15,23,42,0.1)]"
|
||||
>
|
||||
{/* Gradient header */}
|
||||
<div className="relative h-36 overflow-hidden border-b border-gray-100">
|
||||
<div className="absolute inset-0">
|
||||
<PaperMeshGradient seed={skillHub.id} speed={0} />
|
||||
</div>
|
||||
<div className="absolute bottom-[-20px] left-6 flex h-14 w-14 items-center justify-center rounded-[18px] border border-white/60 bg-white shadow-[0_12px_24px_-12px_rgba(15,23,42,0.3)]">
|
||||
<BookOpen className="h-6 w-6 text-gray-700" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-6 pb-5 pt-9">
|
||||
<h2 className="mb-1.5 text-[15px] font-semibold text-gray-900">
|
||||
{skillHub.name}
|
||||
</h2>
|
||||
<p className="line-clamp-2 text-[13px] leading-[1.6] text-gray-400">
|
||||
{skillHub.description || "A curated library of reusable skills for this organization."}
|
||||
</p>
|
||||
|
||||
<div className="mt-5 flex items-center gap-2 border-t border-gray-100 pt-4">
|
||||
<span className="inline-flex rounded-full bg-gray-100 px-3 py-1 text-[12px] font-medium text-gray-500">
|
||||
{skillHub.skills.length} {skillHub.skills.length === 1 ? "Skill" : "Skills"}
|
||||
</span>
|
||||
<span className="ml-auto text-[13px] font-medium text-gray-500">
|
||||
View Hub
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : filteredSkills.length === 0 ? (
|
||||
<div className="rounded-[32px] border border-dashed border-gray-200 bg-white px-6 py-12 text-center">
|
||||
<p className="text-[16px] font-medium tracking-[-0.03em] text-gray-900">
|
||||
{skills.length === 0 ? "No skills have been added yet." : "No skills match that search yet."}
|
||||
</p>
|
||||
<p className="mx-auto mt-3 max-w-[520px] text-[15px] leading-8 text-gray-500">
|
||||
{skills.length === 0
|
||||
? "Add your first skill to start building the hub library, then group it into team-specific hubs."
|
||||
: "Try a broader search or switch back to Hubs to manage curated collections."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-2 2xl:grid-cols-3">
|
||||
{filteredSkills.map((skill) => (
|
||||
<Link
|
||||
key={skill.id}
|
||||
href={getSkillDetailRoute(orgSlug, skill.id)}
|
||||
className="block overflow-hidden rounded-2xl border border-gray-100 bg-white transition hover:-translate-y-0.5 hover:border-gray-200 hover:shadow-[0_8px_24px_-8px_rgba(15,23,42,0.1)]"
|
||||
>
|
||||
{/* Gradient header — seeded by skill id */}
|
||||
<div className="relative h-36 overflow-hidden border-b border-gray-100">
|
||||
<div className="absolute inset-0">
|
||||
<PaperMeshGradient seed={skill.id} speed={0} />
|
||||
</div>
|
||||
<div className="absolute bottom-[-20px] left-6 flex h-14 w-14 items-center justify-center rounded-[18px] border border-white/60 bg-white shadow-[0_12px_24px_-12px_rgba(15,23,42,0.3)]">
|
||||
<FileText className="h-6 w-6 text-gray-700" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-6 pb-5 pt-9">
|
||||
<h2 className="mb-1.5 text-[15px] font-semibold text-gray-900">
|
||||
{skill.title}
|
||||
</h2>
|
||||
<p className="line-clamp-2 text-[13px] leading-[1.6] text-gray-400">
|
||||
{skill.description || "Open this skill to view its instructions."}
|
||||
</p>
|
||||
|
||||
<div className="mt-5 flex items-center gap-2 border-t border-gray-100 pt-4">
|
||||
<span className="inline-flex rounded-full bg-gray-100 px-3 py-1 text-[12px] font-medium text-gray-500">
|
||||
{getSkillVisibilityLabel(skill.shared)}
|
||||
</span>
|
||||
<span className="ml-auto text-[12px] text-gray-400">
|
||||
{formatSkillTimestamp(skill.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</DashboardPageTemplate>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,9 @@
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Search, Share2, Trash2 } from "lucide-react";
|
||||
import { DashboardPageTemplate } from "../../../../_components/ui/dashboard-page-template";
|
||||
import { DenButton, buttonVariants } from "../../../../_components/ui/button";
|
||||
import { DenInput } from "../../../../_components/ui/input";
|
||||
import { requestJson, getErrorMessage } from "../../../../_lib/den-flow";
|
||||
import { getMembersRoute } from "../../../../_lib/den-org";
|
||||
import { useDenFlow } from "../../../../_providers/den-flow-provider";
|
||||
@@ -117,52 +120,28 @@ export function SharedSetupsScreen() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1200px] px-6 py-8 md:px-8">
|
||||
<div className="mb-6 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="mb-1 text-[12px] text-gray-400">{activeOrg?.name ?? "OpenWork Cloud"}</p>
|
||||
<h1 className="text-[28px] font-semibold tracking-[-0.5px] text-gray-900">
|
||||
Team Templates
|
||||
</h1>
|
||||
<p className="mt-2 max-w-2xl text-[14px] leading-relaxed text-gray-500">
|
||||
Browse the shared setups your team has already published from the desktop app.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<a
|
||||
href={OPENWORK_DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded-full border border-gray-200 bg-white px-4 py-2 text-[13px] font-medium text-gray-700 transition-colors hover:bg-gray-50"
|
||||
>
|
||||
Learn how
|
||||
</a>
|
||||
<Link
|
||||
href={getMembersRoute(orgSlug)}
|
||||
className="rounded-full border border-gray-200 bg-white px-4 py-2 text-[13px] font-medium text-gray-700 transition-colors hover:bg-gray-50"
|
||||
>
|
||||
Members
|
||||
</Link>
|
||||
<a
|
||||
href="https://openworklabs.com/download"
|
||||
className="rounded-full bg-gray-900 px-4 py-2 text-[13px] font-medium text-white transition-colors hover:bg-gray-800"
|
||||
>
|
||||
Use desktop app
|
||||
</a>
|
||||
</div>
|
||||
<DashboardPageTemplate
|
||||
icon={Share2}
|
||||
title="Team Templates"
|
||||
description="Browse the shared setups your team has already published from the desktop app."
|
||||
colors={["#FFFBEB", "#78350F", "#F59E0B", "#FDE68A"]}
|
||||
>
|
||||
<div className="mb-4 flex flex-wrap justify-end gap-3">
|
||||
<a href={OPENWORK_DOCS_URL} target="_blank" rel="noreferrer" className={buttonVariants({ variant: "secondary" })}>
|
||||
Learn how
|
||||
</a>
|
||||
<a href="https://openworklabs.com/download" className={buttonVariants({ variant: "primary" })}>
|
||||
Use desktop app
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-6">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-3 flex items-center">
|
||||
<Search className="h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
<div className="mb-6">
|
||||
<DenInput
|
||||
type="text"
|
||||
icon={Search}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search templates"
|
||||
className="w-full rounded-xl border border-gray-200 bg-white py-2.5 pl-9 pr-4 text-[14px] text-gray-900 outline-none transition-all placeholder:text-gray-400 focus:border-gray-300 focus:ring-2 focus:ring-gray-900/5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -237,15 +216,17 @@ export function SharedSetupsScreen() {
|
||||
{activeOrg?.name ?? "Workspace"}
|
||||
</span>
|
||||
{canDelete ? (
|
||||
<button
|
||||
type="button"
|
||||
<DenButton
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
icon={Trash2}
|
||||
loading={deletingId === template.id}
|
||||
disabled={deletingId !== null}
|
||||
onClick={() => void deleteTemplate(template.id)}
|
||||
disabled={deletingId === template.id}
|
||||
className="ml-auto inline-flex items-center gap-1 rounded-full border border-red-200 px-3 py-1.5 text-[11px] font-medium text-red-600 transition-colors hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
className="ml-auto"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
{deletingId === template.id ? "Deleting..." : "Delete"}
|
||||
</button>
|
||||
Delete
|
||||
</DenButton>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
@@ -257,6 +238,6 @@ export function SharedSetupsScreen() {
|
||||
<p className="mt-6 text-[12px] text-gray-400">
|
||||
{orgContext?.members.length ?? 0} members currently have access to this library.
|
||||
</p>
|
||||
</div>
|
||||
</DashboardPageTemplate>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,9 @@ type OrgDashboardContextValue = {
|
||||
cancelInvitation: (invitationId: string) => Promise<void>;
|
||||
updateMemberRole: (memberId: string, role: string) => Promise<void>;
|
||||
removeMember: (memberId: string) => Promise<void>;
|
||||
createTeam: (input: { name: string; memberIds: string[] }) => Promise<void>;
|
||||
updateTeam: (teamId: string, input: { name?: string; memberIds?: string[] }) => Promise<void>;
|
||||
deleteTeam: (teamId: string) => Promise<void>;
|
||||
createRole: (input: { roleName: string; permission: Record<string, string[]> }) => Promise<void>;
|
||||
updateRole: (roleId: string, input: { roleName?: string; permission?: Record<string, string[]> }) => Promise<void>;
|
||||
deleteRole: (roleId: string) => Promise<void>;
|
||||
@@ -257,6 +260,54 @@ export function OrgDashboardProvider({
|
||||
});
|
||||
}
|
||||
|
||||
async function createTeam(input: { name: string; memberIds: string[] }) {
|
||||
await runMutation("create-team", async () => {
|
||||
const { response, payload } = await requestJson(
|
||||
`/v1/orgs/${encodeURIComponent(getRequiredActiveOrgId())}/teams`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
},
|
||||
12000,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(getErrorMessage(payload, `Failed to create team (${response.status}).`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function updateTeam(teamId: string, input: { name?: string; memberIds?: string[] }) {
|
||||
await runMutation("update-team", async () => {
|
||||
const { response, payload } = await requestJson(
|
||||
`/v1/orgs/${encodeURIComponent(getRequiredActiveOrgId())}/teams/${encodeURIComponent(teamId)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
},
|
||||
12000,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(getErrorMessage(payload, `Failed to update team (${response.status}).`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteTeam(teamId: string) {
|
||||
await runMutation("delete-team", async () => {
|
||||
const { response, payload } = await requestJson(
|
||||
`/v1/orgs/${encodeURIComponent(getRequiredActiveOrgId())}/teams/${encodeURIComponent(teamId)}`,
|
||||
{ method: "DELETE" },
|
||||
12000,
|
||||
);
|
||||
|
||||
if (response.status !== 204 && !response.ok) {
|
||||
throw new Error(getErrorMessage(payload, `Failed to delete team (${response.status}).`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function updateRole(roleId: string, input: { roleName?: string; permission?: Record<string, string[]> }) {
|
||||
await runMutation("update-role", async () => {
|
||||
const { response, payload } = await requestJson(
|
||||
@@ -318,6 +369,9 @@ export function OrgDashboardProvider({
|
||||
cancelInvitation,
|
||||
updateMemberRole,
|
||||
removeMember,
|
||||
createTeam,
|
||||
updateTeam,
|
||||
deleteTeam,
|
||||
createRole,
|
||||
updateRole,
|
||||
deleteRole,
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { SkillHubEditorScreen } from "../../../_components/skill-hub-editor-screen";
|
||||
|
||||
export default async function EditSkillHubPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ skillHubId: string }>;
|
||||
}) {
|
||||
const { skillHubId } = await params;
|
||||
|
||||
return <SkillHubEditorScreen skillHubId={skillHubId} />;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { SkillHubDetailScreen } from "../../_components/skill-hub-detail-screen";
|
||||
|
||||
export default async function SkillHubPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ skillHubId: string }>;
|
||||
}) {
|
||||
const { skillHubId } = await params;
|
||||
|
||||
return <SkillHubDetailScreen skillHubId={skillHubId} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SkillHubEditorScreen } from "../../_components/skill-hub-editor-screen";
|
||||
|
||||
export default function NewSkillHubPage() {
|
||||
return <SkillHubEditorScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SkillHubsScreen } from "../_components/skill-hubs-screen";
|
||||
|
||||
export default function SkillHubsPage() {
|
||||
return <SkillHubsScreen />;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { SkillEditorScreen } from "../../../../_components/skill-editor-screen";
|
||||
|
||||
export default async function EditSkillPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ skillId: string }>;
|
||||
}) {
|
||||
const { skillId } = await params;
|
||||
|
||||
return <SkillEditorScreen skillId={skillId} />;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { SkillDetailScreen } from "../../../_components/skill-detail-screen";
|
||||
|
||||
export default async function SkillPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ skillId: string }>;
|
||||
}) {
|
||||
const { skillId } = await params;
|
||||
|
||||
return <SkillDetailScreen skillId={skillId} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SkillEditorScreen } from "../../../_components/skill-editor-screen";
|
||||
|
||||
export default function NewSkillPage() {
|
||||
return <SkillEditorScreen />;
|
||||
}
|
||||
2
ee/apps/den-web/next-env.d.ts
vendored
2
ee/apps/den-web/next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2,25 +2,38 @@ import type {
|
||||
GrainGradientParams,
|
||||
GrainGradientShape,
|
||||
MeshGradientParams,
|
||||
} from "@paper-design/shaders"
|
||||
} from "@paper-design/shaders";
|
||||
|
||||
export type PaperMeshGradientConfig = Required<
|
||||
Pick<
|
||||
MeshGradientParams,
|
||||
"colors" | "distortion" | "swirl" | "grainMixer" | "grainOverlay" | "speed" | "frame"
|
||||
| "colors"
|
||||
| "distortion"
|
||||
| "swirl"
|
||||
| "grainMixer"
|
||||
| "grainOverlay"
|
||||
| "speed"
|
||||
| "frame"
|
||||
>
|
||||
>
|
||||
>;
|
||||
|
||||
export type PaperGrainGradientConfig = Required<
|
||||
Pick<
|
||||
GrainGradientParams,
|
||||
"colorBack" | "colors" | "softness" | "intensity" | "noise" | "shape" | "speed" | "frame"
|
||||
| "colorBack"
|
||||
| "colors"
|
||||
| "softness"
|
||||
| "intensity"
|
||||
| "noise"
|
||||
| "shape"
|
||||
| "speed"
|
||||
| "frame"
|
||||
>
|
||||
>
|
||||
>;
|
||||
|
||||
export type SeededPaperOption = {
|
||||
seed?: string
|
||||
}
|
||||
seed?: string;
|
||||
};
|
||||
|
||||
export const paperMeshGradientDefaults: PaperMeshGradientConfig = {
|
||||
colors: ["#e0eaff", "#241d9a", "#f75092", "#9f50d3"],
|
||||
@@ -30,7 +43,7 @@ export const paperMeshGradientDefaults: PaperMeshGradientConfig = {
|
||||
grainOverlay: 0,
|
||||
speed: 0.1,
|
||||
frame: 0,
|
||||
}
|
||||
};
|
||||
|
||||
export const paperGrainGradientDefaults: PaperGrainGradientConfig = {
|
||||
colors: ["#7300ff", "#eba8ff", "#00bfff", "#2b00ff"],
|
||||
@@ -41,7 +54,7 @@ export const paperGrainGradientDefaults: PaperGrainGradientConfig = {
|
||||
shape: "ripple",
|
||||
speed: 0.4,
|
||||
frame: 0,
|
||||
}
|
||||
};
|
||||
|
||||
const grainShapes: GrainGradientShape[] = [
|
||||
"corners",
|
||||
@@ -51,7 +64,7 @@ const grainShapes: GrainGradientShape[] = [
|
||||
"ripple",
|
||||
"blob",
|
||||
"sphere",
|
||||
]
|
||||
];
|
||||
|
||||
const meshPaletteFamilies = [
|
||||
["#e0eaff", "#241d9a", "#f75092", "#9f50d3"],
|
||||
@@ -62,7 +75,7 @@ const meshPaletteFamilies = [
|
||||
["#f0ffe1", "#254d00", "#8cc63f", "#00a76f"],
|
||||
["#f5edff", "#44206b", "#b5179e", "#7209b7"],
|
||||
["#f4f1ea", "#3a2f1f", "#927c55", "#d0c2a8"],
|
||||
]
|
||||
];
|
||||
|
||||
const grainPaletteFamilies = [
|
||||
["#7300ff", "#eba8ff", "#00bfff", "#2b00ff"],
|
||||
@@ -73,7 +86,7 @@ const grainPaletteFamilies = [
|
||||
["#b9ecff", "#006494", "#00a6a6", "#072ac8"],
|
||||
["#f7f0d6", "#8c5e34", "#d68c45", "#4e342e"],
|
||||
["#ffd9f5", "#ff006e", "#8338ec", "#3a0ca3"],
|
||||
]
|
||||
];
|
||||
|
||||
const paletteModes = [
|
||||
{
|
||||
@@ -106,41 +119,57 @@ const paletteModes = [
|
||||
saturations: [0.9, 0.72, 0.8, 0.82],
|
||||
lightnesses: [0.8, 0.38, 0.52, 0.56],
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
type MeshGradientOverrides = SeededPaperOption & Partial<PaperMeshGradientConfig>
|
||||
type GrainGradientOverrides = SeededPaperOption & Partial<PaperGrainGradientConfig>
|
||||
type MeshGradientOverrides = SeededPaperOption &
|
||||
Partial<PaperMeshGradientConfig>;
|
||||
type GrainGradientOverrides = SeededPaperOption &
|
||||
Partial<PaperGrainGradientConfig>;
|
||||
|
||||
export function getSeededPaperMeshGradientConfig(seed: string): PaperMeshGradientConfig {
|
||||
const random = createRandom(seed, "mesh")
|
||||
export function getSeededPaperMeshGradientConfig(
|
||||
seed: string,
|
||||
): PaperMeshGradientConfig {
|
||||
const random = createRandom(seed, "mesh");
|
||||
|
||||
return {
|
||||
colors: createSeededPalette(paperMeshGradientDefaults.colors, seed, "mesh-colors", {
|
||||
families: meshPaletteFamilies,
|
||||
hueShift: 42,
|
||||
saturationShift: 0.18,
|
||||
lightnessShift: 0.14,
|
||||
baseBlend: [0.08, 0.2],
|
||||
}),
|
||||
colors: createSeededPalette(
|
||||
paperMeshGradientDefaults.colors,
|
||||
seed,
|
||||
"mesh-colors",
|
||||
{
|
||||
families: meshPaletteFamilies,
|
||||
hueShift: 42,
|
||||
saturationShift: 0.18,
|
||||
lightnessShift: 0.14,
|
||||
baseBlend: [0.08, 0.2],
|
||||
},
|
||||
),
|
||||
distortion: roundTo(clamp(0.58 + random() * 0.32, 0, 1), 3),
|
||||
swirl: roundTo(clamp(0.03 + random() * 0.28, 0, 1), 3),
|
||||
grainMixer: roundTo(clamp(random() * 0.18, 0, 1), 3),
|
||||
grainOverlay: roundTo(clamp(random() * 0.12, 0, 1), 3),
|
||||
speed: roundTo(0.05 + random() * 0.11, 3),
|
||||
speed: 0.5,
|
||||
frame: Math.round(random() * 240000),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function getSeededPaperGrainGradientConfig(seed: string): PaperGrainGradientConfig {
|
||||
const random = createRandom(seed, "grain")
|
||||
const colors = createSeededPalette(paperGrainGradientDefaults.colors, seed, "grain-colors", {
|
||||
families: grainPaletteFamilies,
|
||||
hueShift: 58,
|
||||
saturationShift: 0.22,
|
||||
lightnessShift: 0.18,
|
||||
baseBlend: [0.04, 0.14],
|
||||
})
|
||||
const anchorColor = colors[Math.floor(random() * colors.length)] ?? colors[0]
|
||||
export function getSeededPaperGrainGradientConfig(
|
||||
seed: string,
|
||||
): PaperGrainGradientConfig {
|
||||
const random = createRandom(seed, "grain");
|
||||
const colors = createSeededPalette(
|
||||
paperGrainGradientDefaults.colors,
|
||||
seed,
|
||||
"grain-colors",
|
||||
{
|
||||
families: grainPaletteFamilies,
|
||||
hueShift: 58,
|
||||
saturationShift: 0.22,
|
||||
lightnessShift: 0.18,
|
||||
baseBlend: [0.04, 0.14],
|
||||
},
|
||||
);
|
||||
const anchorColor = colors[Math.floor(random() * colors.length)] ?? colors[0];
|
||||
|
||||
return {
|
||||
colors,
|
||||
@@ -148,16 +177,20 @@ export function getSeededPaperGrainGradientConfig(seed: string): PaperGrainGradi
|
||||
softness: roundTo(clamp(0.22 + random() * 0.56, 0, 1), 3),
|
||||
intensity: roundTo(clamp(0.2 + random() * 0.6, 0, 1), 3),
|
||||
noise: roundTo(clamp(0.12 + random() * 0.34, 0, 1), 3),
|
||||
shape: grainShapes[Math.floor(random() * grainShapes.length)] ?? paperGrainGradientDefaults.shape,
|
||||
shape:
|
||||
grainShapes[Math.floor(random() * grainShapes.length)] ??
|
||||
paperGrainGradientDefaults.shape,
|
||||
speed: roundTo(0.2 + random() * 0.6, 3),
|
||||
frame: Math.round(random() * 320000),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePaperMeshGradientConfig(
|
||||
options: MeshGradientOverrides = {},
|
||||
): PaperMeshGradientConfig {
|
||||
const seeded = options.seed ? getSeededPaperMeshGradientConfig(options.seed) : paperMeshGradientDefaults
|
||||
const seeded = options.seed
|
||||
? getSeededPaperMeshGradientConfig(options.seed)
|
||||
: paperMeshGradientDefaults;
|
||||
|
||||
return {
|
||||
colors: options.colors ?? seeded.colors,
|
||||
@@ -167,13 +200,15 @@ export function resolvePaperMeshGradientConfig(
|
||||
grainOverlay: options.grainOverlay ?? seeded.grainOverlay,
|
||||
speed: options.speed ?? seeded.speed,
|
||||
frame: options.frame ?? seeded.frame,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePaperGrainGradientConfig(
|
||||
options: GrainGradientOverrides = {},
|
||||
): PaperGrainGradientConfig {
|
||||
const seeded = options.seed ? getSeededPaperGrainGradientConfig(options.seed) : paperGrainGradientDefaults
|
||||
const seeded = options.seed
|
||||
? getSeededPaperGrainGradientConfig(options.seed)
|
||||
: paperGrainGradientDefaults;
|
||||
|
||||
return {
|
||||
colors: options.colors ?? seeded.colors,
|
||||
@@ -184,22 +219,22 @@ export function resolvePaperGrainGradientConfig(
|
||||
shape: options.shape ?? seeded.shape,
|
||||
speed: options.speed ?? seeded.speed,
|
||||
frame: options.frame ?? seeded.frame,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function buildSeedSource(seed: string) {
|
||||
const trimmedSeed = seed.trim()
|
||||
const separatorIndex = trimmedSeed.indexOf("_")
|
||||
const trimmedSeed = seed.trim();
|
||||
const separatorIndex = trimmedSeed.indexOf("_");
|
||||
|
||||
if (separatorIndex === -1) {
|
||||
return trimmedSeed
|
||||
return trimmedSeed;
|
||||
}
|
||||
|
||||
const prefix = trimmedSeed.slice(0, separatorIndex)
|
||||
const suffix = trimmedSeed.slice(separatorIndex + 1)
|
||||
const suffixTail = suffix.slice(5) || suffix
|
||||
const prefix = trimmedSeed.slice(0, separatorIndex);
|
||||
const suffix = trimmedSeed.slice(separatorIndex + 1);
|
||||
const suffixTail = suffix.slice(5) || suffix;
|
||||
|
||||
return `${trimmedSeed}|${prefix}|${suffix}|${suffixTail}`
|
||||
return `${trimmedSeed}|${prefix}|${suffix}|${suffixTail}`;
|
||||
}
|
||||
|
||||
function createSeededPalette(
|
||||
@@ -207,210 +242,258 @@ function createSeededPalette(
|
||||
seed: string,
|
||||
namespace: string,
|
||||
options: {
|
||||
families: string[][]
|
||||
hueShift: number
|
||||
saturationShift: number
|
||||
lightnessShift: number
|
||||
baseBlend: [number, number]
|
||||
families: string[][];
|
||||
hueShift: number;
|
||||
saturationShift: number;
|
||||
lightnessShift: number;
|
||||
baseBlend: [number, number];
|
||||
},
|
||||
) {
|
||||
const familyRandom = createRandom(seed, `${namespace}:family`)
|
||||
const primaryIndex = Math.floor(familyRandom() * options.families.length)
|
||||
const secondaryOffset = 1 + Math.floor(familyRandom() * (options.families.length - 1))
|
||||
const secondaryIndex = (primaryIndex + secondaryOffset) % options.families.length
|
||||
const primary = options.families[primaryIndex] ?? baseColors
|
||||
const secondary = options.families[secondaryIndex] ?? [...baseColors].reverse()
|
||||
const primaryShift = Math.floor(familyRandom() * primary.length)
|
||||
const secondaryShift = Math.floor(familyRandom() * secondary.length)
|
||||
const paletteMode = paletteModes[Math.floor(familyRandom() * paletteModes.length)] ?? paletteModes[0]
|
||||
const baseHue = familyRandom() * 360
|
||||
const familyRandom = createRandom(seed, `${namespace}:family`);
|
||||
const primaryIndex = Math.floor(familyRandom() * options.families.length);
|
||||
const secondaryOffset =
|
||||
1 + Math.floor(familyRandom() * (options.families.length - 1));
|
||||
const secondaryIndex =
|
||||
(primaryIndex + secondaryOffset) % options.families.length;
|
||||
const primary = options.families[primaryIndex] ?? baseColors;
|
||||
const secondary =
|
||||
options.families[secondaryIndex] ?? [...baseColors].reverse();
|
||||
const primaryShift = Math.floor(familyRandom() * primary.length);
|
||||
const secondaryShift = Math.floor(familyRandom() * secondary.length);
|
||||
const paletteMode =
|
||||
paletteModes[Math.floor(familyRandom() * paletteModes.length)] ??
|
||||
paletteModes[0];
|
||||
const baseHue = familyRandom() * 360;
|
||||
|
||||
return baseColors.map((color, index) => {
|
||||
const random = createRandom(seed, `${namespace}:${index}`)
|
||||
const primaryColor = primary[(index + primaryShift) % primary.length] ?? color
|
||||
const secondaryColor = secondary[(index + secondaryShift) % secondary.length] ?? primaryColor
|
||||
const random = createRandom(seed, `${namespace}:${index}`);
|
||||
const primaryColor =
|
||||
primary[(index + primaryShift) % primary.length] ?? color;
|
||||
const secondaryColor =
|
||||
secondary[(index + secondaryShift) % secondary.length] ?? primaryColor;
|
||||
const proceduralColor = hslToHex(
|
||||
(baseHue + paletteMode.hueOffsets[index % paletteMode.hueOffsets.length] + (random() * 2 - 1) * 18 + 360) % 360,
|
||||
clamp(paletteMode.saturations[index % paletteMode.saturations.length] + (random() * 2 - 1) * 0.08, 0, 1),
|
||||
clamp(paletteMode.lightnesses[index % paletteMode.lightnesses.length] + (random() * 2 - 1) * 0.08, 0, 1),
|
||||
)
|
||||
const mixedFamilyColor = mixHexColors(primaryColor, secondaryColor, 0.18 + random() * 0.64)
|
||||
(baseHue +
|
||||
paletteMode.hueOffsets[index % paletteMode.hueOffsets.length] +
|
||||
(random() * 2 - 1) * 18 +
|
||||
360) %
|
||||
360,
|
||||
clamp(
|
||||
paletteMode.saturations[index % paletteMode.saturations.length] +
|
||||
(random() * 2 - 1) * 0.08,
|
||||
0,
|
||||
1,
|
||||
),
|
||||
clamp(
|
||||
paletteMode.lightnesses[index % paletteMode.lightnesses.length] +
|
||||
(random() * 2 - 1) * 0.08,
|
||||
0,
|
||||
1,
|
||||
),
|
||||
);
|
||||
const mixedFamilyColor = mixHexColors(
|
||||
primaryColor,
|
||||
secondaryColor,
|
||||
0.18 + random() * 0.64,
|
||||
);
|
||||
const remixedFamilyColor = mixHexColors(
|
||||
mixedFamilyColor,
|
||||
primary[(index + secondaryShift + 1) % primary.length] ?? mixedFamilyColor,
|
||||
primary[(index + secondaryShift + 1) % primary.length] ??
|
||||
mixedFamilyColor,
|
||||
random() * 0.32,
|
||||
)
|
||||
const proceduralFamilyColor = mixHexColors(proceduralColor, remixedFamilyColor, 0.22 + random() * 0.34)
|
||||
const [minBaseBlend, maxBaseBlend] = options.baseBlend
|
||||
);
|
||||
const proceduralFamilyColor = mixHexColors(
|
||||
proceduralColor,
|
||||
remixedFamilyColor,
|
||||
0.22 + random() * 0.34,
|
||||
);
|
||||
const [minBaseBlend, maxBaseBlend] = options.baseBlend;
|
||||
const blendedBaseColor = mixHexColors(
|
||||
proceduralFamilyColor,
|
||||
color,
|
||||
minBaseBlend + random() * (maxBaseBlend - minBaseBlend),
|
||||
)
|
||||
);
|
||||
|
||||
return adjustHexColor(blendedBaseColor, {
|
||||
hueShift: (random() * 2 - 1) * options.hueShift + (random() * 2 - 1) * 14,
|
||||
saturationShift: (random() * 2 - 1) * options.saturationShift + 0.06,
|
||||
lightnessShift: (random() * 2 - 1) * options.lightnessShift,
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createSeededBackground(baseColor: string, seed: string, namespace: string) {
|
||||
const [red, green, blue] = hexToRgb(baseColor)
|
||||
const [hue] = rgbToHsl(red, green, blue)
|
||||
const random = createRandom(seed, namespace)
|
||||
function createSeededBackground(
|
||||
baseColor: string,
|
||||
seed: string,
|
||||
namespace: string,
|
||||
) {
|
||||
const [red, green, blue] = hexToRgb(baseColor);
|
||||
const [hue] = rgbToHsl(red, green, blue);
|
||||
const random = createRandom(seed, namespace);
|
||||
|
||||
return hslToHex(
|
||||
hue,
|
||||
clamp(0.18 + random() * 0.18, 0, 1),
|
||||
clamp(0.03 + random() * 0.09, 0, 1),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function adjustHexColor(
|
||||
hex: string,
|
||||
adjustments: { hueShift: number; saturationShift: number; lightnessShift: number },
|
||||
adjustments: {
|
||||
hueShift: number;
|
||||
saturationShift: number;
|
||||
lightnessShift: number;
|
||||
},
|
||||
) {
|
||||
const [red, green, blue] = hexToRgb(hex)
|
||||
const [hue, saturation, lightness] = rgbToHsl(red, green, blue)
|
||||
const [red, green, blue] = hexToRgb(hex);
|
||||
const [hue, saturation, lightness] = rgbToHsl(red, green, blue);
|
||||
|
||||
return hslToHex(
|
||||
(hue + adjustments.hueShift + 360) % 360,
|
||||
clamp(saturation + adjustments.saturationShift, 0, 1),
|
||||
clamp(lightness + adjustments.lightnessShift, 0, 1),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function mixHexColors(colorA: string, colorB: string, amount: number) {
|
||||
const [redA, greenA, blueA] = hexToRgb(colorA)
|
||||
const [redB, greenB, blueB] = hexToRgb(colorB)
|
||||
const mixAmount = clamp(amount, 0, 1)
|
||||
const [redA, greenA, blueA] = hexToRgb(colorA);
|
||||
const [redB, greenB, blueB] = hexToRgb(colorB);
|
||||
const mixAmount = clamp(amount, 0, 1);
|
||||
|
||||
return rgbToHex(
|
||||
Math.round(redA + (redB - redA) * mixAmount),
|
||||
Math.round(greenA + (greenB - greenA) * mixAmount),
|
||||
Math.round(blueA + (blueB - blueA) * mixAmount),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function createRandom(seed: string, namespace: string) {
|
||||
return mulberry32(hashString(`${buildSeedSource(seed)}::${namespace}`))
|
||||
return mulberry32(hashString(`${buildSeedSource(seed)}::${namespace}`));
|
||||
}
|
||||
|
||||
function hashString(input: string) {
|
||||
let hash = 2166136261
|
||||
let hash = 2166136261;
|
||||
|
||||
for (let index = 0; index < input.length; index += 1) {
|
||||
hash ^= input.charCodeAt(index)
|
||||
hash = Math.imul(hash, 16777619)
|
||||
hash ^= input.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
|
||||
return hash >>> 0
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
function mulberry32(seed: number) {
|
||||
return function nextRandom() {
|
||||
let value = seed += 0x6d2b79f5
|
||||
value = Math.imul(value ^ (value >>> 15), value | 1)
|
||||
value ^= value + Math.imul(value ^ (value >>> 7), value | 61)
|
||||
return ((value ^ (value >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
let value = (seed += 0x6d2b79f5);
|
||||
value = Math.imul(value ^ (value >>> 15), value | 1);
|
||||
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
|
||||
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function roundTo(value: number, precision: number) {
|
||||
const power = 10 ** precision
|
||||
return Math.round(value * power) / power
|
||||
const power = 10 ** precision;
|
||||
return Math.round(value * power) / power;
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string): [number, number, number] {
|
||||
const normalized = hex.replace(/^#/, "")
|
||||
const expanded = normalized.length === 3
|
||||
? normalized.split("").map((part) => `${part}${part}`).join("")
|
||||
: normalized
|
||||
const normalized = hex.replace(/^#/, "");
|
||||
const expanded =
|
||||
normalized.length === 3
|
||||
? normalized
|
||||
.split("")
|
||||
.map((part) => `${part}${part}`)
|
||||
.join("")
|
||||
: normalized;
|
||||
|
||||
if (expanded.length !== 6) {
|
||||
throw new Error(`Unsupported hex color: ${hex}`)
|
||||
throw new Error(`Unsupported hex color: ${hex}`);
|
||||
}
|
||||
|
||||
const value = Number.parseInt(expanded, 16)
|
||||
const value = Number.parseInt(expanded, 16);
|
||||
|
||||
return [
|
||||
(value >> 16) & 255,
|
||||
(value >> 8) & 255,
|
||||
value & 255,
|
||||
]
|
||||
return [(value >> 16) & 255, (value >> 8) & 255, value & 255];
|
||||
}
|
||||
|
||||
function rgbToHsl(red: number, green: number, blue: number): [number, number, number] {
|
||||
const normalizedRed = red / 255
|
||||
const normalizedGreen = green / 255
|
||||
const normalizedBlue = blue / 255
|
||||
const max = Math.max(normalizedRed, normalizedGreen, normalizedBlue)
|
||||
const min = Math.min(normalizedRed, normalizedGreen, normalizedBlue)
|
||||
const lightness = (max + min) / 2
|
||||
function rgbToHsl(
|
||||
red: number,
|
||||
green: number,
|
||||
blue: number,
|
||||
): [number, number, number] {
|
||||
const normalizedRed = red / 255;
|
||||
const normalizedGreen = green / 255;
|
||||
const normalizedBlue = blue / 255;
|
||||
const max = Math.max(normalizedRed, normalizedGreen, normalizedBlue);
|
||||
const min = Math.min(normalizedRed, normalizedGreen, normalizedBlue);
|
||||
const lightness = (max + min) / 2;
|
||||
|
||||
if (max === min) {
|
||||
return [0, 0, lightness]
|
||||
return [0, 0, lightness];
|
||||
}
|
||||
|
||||
const delta = max - min
|
||||
const saturation = lightness > 0.5
|
||||
? delta / (2 - max - min)
|
||||
: delta / (max + min)
|
||||
const delta = max - min;
|
||||
const saturation =
|
||||
lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min);
|
||||
|
||||
let hue = 0
|
||||
let hue = 0;
|
||||
|
||||
switch (max) {
|
||||
case normalizedRed:
|
||||
hue = (normalizedGreen - normalizedBlue) / delta + (normalizedGreen < normalizedBlue ? 6 : 0)
|
||||
break
|
||||
hue =
|
||||
(normalizedGreen - normalizedBlue) / delta +
|
||||
(normalizedGreen < normalizedBlue ? 6 : 0);
|
||||
break;
|
||||
case normalizedGreen:
|
||||
hue = (normalizedBlue - normalizedRed) / delta + 2
|
||||
break
|
||||
hue = (normalizedBlue - normalizedRed) / delta + 2;
|
||||
break;
|
||||
default:
|
||||
hue = (normalizedRed - normalizedGreen) / delta + 4
|
||||
break
|
||||
hue = (normalizedRed - normalizedGreen) / delta + 4;
|
||||
break;
|
||||
}
|
||||
|
||||
return [hue * 60, saturation, lightness]
|
||||
return [hue * 60, saturation, lightness];
|
||||
}
|
||||
|
||||
function hslToHex(hue: number, saturation: number, lightness: number) {
|
||||
if (saturation === 0) {
|
||||
const value = Math.round(lightness * 255)
|
||||
return rgbToHex(value, value, value)
|
||||
const value = Math.round(lightness * 255);
|
||||
return rgbToHex(value, value, value);
|
||||
}
|
||||
|
||||
const hueToRgb = (p: number, q: number, t: number) => {
|
||||
let normalizedT = t
|
||||
let normalizedT = t;
|
||||
|
||||
if (normalizedT < 0) normalizedT += 1
|
||||
if (normalizedT > 1) normalizedT -= 1
|
||||
if (normalizedT < 1 / 6) return p + (q - p) * 6 * normalizedT
|
||||
if (normalizedT < 1 / 2) return q
|
||||
if (normalizedT < 2 / 3) return p + (q - p) * (2 / 3 - normalizedT) * 6
|
||||
return p
|
||||
}
|
||||
if (normalizedT < 0) normalizedT += 1;
|
||||
if (normalizedT > 1) normalizedT -= 1;
|
||||
if (normalizedT < 1 / 6) return p + (q - p) * 6 * normalizedT;
|
||||
if (normalizedT < 1 / 2) return q;
|
||||
if (normalizedT < 2 / 3) return p + (q - p) * (2 / 3 - normalizedT) * 6;
|
||||
return p;
|
||||
};
|
||||
|
||||
const normalizedHue = hue / 360
|
||||
const q = lightness < 0.5
|
||||
? lightness * (1 + saturation)
|
||||
: lightness + saturation - lightness * saturation
|
||||
const p = 2 * lightness - q
|
||||
const red = hueToRgb(p, q, normalizedHue + 1 / 3)
|
||||
const green = hueToRgb(p, q, normalizedHue)
|
||||
const blue = hueToRgb(p, q, normalizedHue - 1 / 3)
|
||||
const normalizedHue = hue / 360;
|
||||
const q =
|
||||
lightness < 0.5
|
||||
? lightness * (1 + saturation)
|
||||
: lightness + saturation - lightness * saturation;
|
||||
const p = 2 * lightness - q;
|
||||
const red = hueToRgb(p, q, normalizedHue + 1 / 3);
|
||||
const green = hueToRgb(p, q, normalizedHue);
|
||||
const blue = hueToRgb(p, q, normalizedHue - 1 / 3);
|
||||
|
||||
return rgbToHex(Math.round(red * 255), Math.round(green * 255), Math.round(blue * 255))
|
||||
return rgbToHex(
|
||||
Math.round(red * 255),
|
||||
Math.round(green * 255),
|
||||
Math.round(blue * 255),
|
||||
);
|
||||
}
|
||||
|
||||
function rgbToHex(red: number, green: number, blue: number) {
|
||||
return `#${[red, green, blue]
|
||||
.map((value) => value.toString(16).padStart(2, "0"))
|
||||
.join("")}`
|
||||
.join("")}`;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ const rootDir = path.resolve(__dirname, "..")
|
||||
const composeFile = path.join(rootDir, "packaging", "docker", "docker-compose.web-local.yml")
|
||||
const composeProject = "openwork-den-local"
|
||||
|
||||
const controllerPort = process.env.DEN_CONTROLLER_PORT?.trim() || "8788"
|
||||
const apiPort = process.env.DEN_API_PORT?.trim() || process.env.DEN_CONTROLLER_PORT?.trim() || "8788"
|
||||
const workerProxyPort = process.env.DEN_WORKER_PROXY_PORT?.trim() || "8789"
|
||||
const webPort = process.env.DEN_WEB_PORT?.trim() || "3005"
|
||||
const databaseUrl = process.env.DATABASE_URL?.trim() || "mysql://root:password@127.0.0.1:3306/openwork_den"
|
||||
@@ -143,7 +143,7 @@ for (const signal of ["SIGINT", "SIGTERM"]) {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
for (const [name, port] of [["den-web", webPort], ["den-controller", controllerPort], ["den-worker-proxy", workerProxyPort]]) {
|
||||
for (const [name, port] of [["den-web", webPort], ["den-api", apiPort], ["den-worker-proxy", workerProxyPort]]) {
|
||||
const available = await canListenOnPort(Number(port))
|
||||
if (!available) {
|
||||
throw new Error(`${name} local port ${port} is already in use. Stop the existing process or rerun with a different port env override.`)
|
||||
@@ -184,9 +184,9 @@ async function main() {
|
||||
"run",
|
||||
"dev:local",
|
||||
"--output-logs=full",
|
||||
"--filter=@openwork-ee/den-controller",
|
||||
"--filter=@openwork-ee/den-worker-proxy",
|
||||
"--filter=@openwork-ee/den-web",
|
||||
"--filter=@openwork-ee/den-api",
|
||||
"--filter=@openwork-ee/den-worker-proxy",
|
||||
"--filter=@openwork-ee/den-web",
|
||||
],
|
||||
{
|
||||
cwd: rootDir,
|
||||
@@ -200,12 +200,13 @@ async function main() {
|
||||
BETTER_AUTH_URL: process.env.BETTER_AUTH_URL?.trim() || `http://localhost:${webPort}`,
|
||||
DEN_BETTER_AUTH_TRUSTED_ORIGINS: process.env.DEN_BETTER_AUTH_TRUSTED_ORIGINS?.trim() || webOrigins,
|
||||
CORS_ORIGINS: process.env.CORS_ORIGINS?.trim() || webOrigins,
|
||||
DEN_CONTROLLER_PORT: controllerPort,
|
||||
DEN_API_PORT: apiPort,
|
||||
DEN_CONTROLLER_PORT: apiPort,
|
||||
DEN_WORKER_PROXY_PORT: workerProxyPort,
|
||||
DEN_WEB_PORT: webPort,
|
||||
DEN_API_BASE: process.env.DEN_API_BASE?.trim() || `http://127.0.0.1:${controllerPort}`,
|
||||
DEN_API_BASE: process.env.DEN_API_BASE?.trim() || `http://127.0.0.1:${apiPort}`,
|
||||
DEN_AUTH_ORIGIN: process.env.DEN_AUTH_ORIGIN?.trim() || `http://localhost:${webPort}`,
|
||||
DEN_AUTH_FALLBACK_BASE: process.env.DEN_AUTH_FALLBACK_BASE?.trim() || `http://127.0.0.1:${controllerPort}`,
|
||||
DEN_AUTH_FALLBACK_BASE: process.env.DEN_AUTH_FALLBACK_BASE?.trim() || `http://127.0.0.1:${apiPort}`,
|
||||
PROVISIONER_MODE: process.env.PROVISIONER_MODE?.trim() || "stub",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"BETTER_AUTH_URL",
|
||||
"DEN_BETTER_AUTH_TRUSTED_ORIGINS",
|
||||
"CORS_ORIGINS",
|
||||
"DEN_API_PORT",
|
||||
"DEN_CONTROLLER_PORT",
|
||||
"DEN_WORKER_PROXY_PORT",
|
||||
"DEN_WEB_PORT",
|
||||
|
||||
Reference in New Issue
Block a user