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:
Source Open
2026-04-02 10:28:47 -07:00
committed by GitHub
parent c4737e0236
commit ec33bae663
36 changed files with 3790 additions and 737 deletions

View File

@@ -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"]

View File

@@ -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)
}

View File

@@ -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")
}

View File

@@ -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,

View 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)
},
)
}

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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(" ")}
/>
);
}

View File

@@ -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,
};
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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",

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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,
};
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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,

View File

@@ -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} />;
}

View File

@@ -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} />;
}

View File

@@ -0,0 +1,5 @@
import { SkillHubEditorScreen } from "../../_components/skill-hub-editor-screen";
export default function NewSkillHubPage() {
return <SkillHubEditorScreen />;
}

View File

@@ -0,0 +1,5 @@
import { SkillHubsScreen } from "../_components/skill-hubs-screen";
export default function SkillHubsPage() {
return <SkillHubsScreen />;
}

View File

@@ -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} />;
}

View File

@@ -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} />;
}

View File

@@ -0,0 +1,5 @@
import { SkillEditorScreen } from "../../../_components/skill-editor-screen";
export default function NewSkillPage() {
return <SkillEditorScreen />;
}

View File

@@ -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

View File

@@ -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("")}`;
}

View File

@@ -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",
},
},

View File

@@ -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",