Add app clip share and import UI

This commit is contained in:
Dotta committed 2026-05-16 09:39:23 -05:00
1 parent 881ada6e02
commit dd75b4b204
14 files changed
+1263 -6

No files matched your search

+85 -2
View File
@@ -1,4 +1,3 @@
import { createHash } from "node:crypto";
import { z } from "zod";
import type {
CompanyPortabilityAgentManifestEntry,
@@ -924,7 +923,91 @@ function cloneManifest(manifest: CompanyPortabilityManifest): CompanyPortability
}
function sha256Stable(value: unknown) {
return `sha256:${createHash("sha256").update(stableStringify(value)).digest("hex")}`;
return `sha256:${sha256Hex(stableStringify(value))}`;
}
const SHA256_K = [
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
];
function sha256Hex(input: string) {
const bytes = new TextEncoder().encode(input);
const bitLengthHigh = Math.floor((bytes.length * 8) / 0x100000000);
const bitLengthLow = (bytes.length * 8) >>> 0;
const paddedLength = (((bytes.length + 9 + 63) >> 6) << 6);
const padded = new Uint8Array(paddedLength);
padded.set(bytes);
padded[bytes.length] = 0x80;
padded[paddedLength - 8] = bitLengthHigh >>> 24;
padded[paddedLength - 7] = bitLengthHigh >>> 16;
padded[paddedLength - 6] = bitLengthHigh >>> 8;
padded[paddedLength - 5] = bitLengthHigh;
padded[paddedLength - 4] = bitLengthLow >>> 24;
padded[paddedLength - 3] = bitLengthLow >>> 16;
padded[paddedLength - 2] = bitLengthLow >>> 8;
padded[paddedLength - 1] = bitLengthLow;
const state = [
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
];
const words = new Uint32Array(64);
for (let offset = 0; offset < padded.length; offset += 64) {
for (let index = 0; index < 16; index += 1) {
const wordOffset = offset + index * 4;
words[index] =
((padded[wordOffset] << 24) |
(padded[wordOffset + 1] << 16) |
(padded[wordOffset + 2] << 8) |
padded[wordOffset + 3]) >>> 0;
}
for (let index = 16; index < 64; index += 1) {
const s0 = rotateRight(words[index - 15], 7) ^ rotateRight(words[index - 15], 18) ^ (words[index - 15] >>> 3);
const s1 = rotateRight(words[index - 2], 17) ^ rotateRight(words[index - 2], 19) ^ (words[index - 2] >>> 10);
words[index] = (words[index - 16] + s0 + words[index - 7] + s1) >>> 0;
}
let [a, b, c, d, e, f, g, h] = state;
for (let index = 0; index < 64; index += 1) {
const s1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25);
const ch = (e & f) ^ (~e & g);
const temp1 = (h + s1 + ch + SHA256_K[index] + words[index]) >>> 0;
const s0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22);
const maj = (a & b) ^ (a & c) ^ (b & c);
const temp2 = (s0 + maj) >>> 0;
h = g;
g = f;
f = e;
e = (d + temp1) >>> 0;
d = c;
c = b;
b = a;
a = (temp1 + temp2) >>> 0;
}
state[0] = (state[0] + a) >>> 0;
state[1] = (state[1] + b) >>> 0;
state[2] = (state[2] + c) >>> 0;
state[3] = (state[3] + d) >>> 0;
state[4] = (state[4] + e) >>> 0;
state[5] = (state[5] + f) >>> 0;
state[6] = (state[6] + g) >>> 0;
state[7] = (state[7] + h) >>> 0;
}
return state.map((word) => word.toString(16).padStart(8, "0")).join("");
}
function rotateRight(value: number, bits: number) {
return (value >>> bits) | (value << (32 - bits));
}
function stableStringify(value: unknown): string {
+12
View File
@@ -730,6 +730,12 @@ export {
type ClipDependency,
type ClipRevision,
type PublicClip,
type ClipSharePreviewRequest,
type ClipSharePreviewResult,
type ClipImportPreviewRequest,
type ClipImportPreviewResult,
type ClipImportApplyRequest,
type ClipImportApplyResult,
} from "./types/clip.js";
export {
@@ -779,6 +785,9 @@ export {
createClipCommentSchema,
createClipShowcaseSchema,
createClipImportTelemetrySchema,
clipSharePreviewSchema,
clipImportPreviewSchema,
clipImportApplySchema,
type CreateClipCreatorProfile,
type PublishClip,
type CreateClipRevision,
@@ -786,6 +795,9 @@ export {
type CreateClipVote,
type CreateClipReport,
type CreateClipComment,
type ClipSharePreview,
type ClipImportPreview,
type ClipImportApply,
type CreateClipShowcase,
type CreateClipImportTelemetry,
environmentDriverSchema,
+72
View File
@@ -142,3 +142,75 @@ export interface PublicClip {
createdAt: Date | string;
updatedAt: Date | string;
}
export interface ClipSharePreviewRequest {
source: {
type: ClipType;
id: string;
};
title?: string;
summary?: string;
slug?: string;
visibility?: ClipVisibility;
revisionNote?: string | null;
}
export interface ClipSharePreviewResult {
source: {
type: ClipType;
id: string;
label: string;
};
publishRequest: Record<string, unknown>;
exportPreview: Record<string, unknown>;
manifest: Record<string, unknown>;
dependencyCounts: {
adapters: number;
plugins: number;
skills: number;
secrets: number;
permissions: number;
workspaces: number;
};
redactionSummary: {
allowed: number;
redacted: number;
summarized: number;
omitted: number;
};
dangerousCapabilities: string[];
warnings: string[];
}
export interface ClipImportPreviewRequest {
url: string;
collisionStrategy?: "rename" | "skip";
}
export interface ClipImportPreviewResult {
clip: PublicClip;
preview: Record<string, unknown>;
safety: {
dangerousCapabilities: string[];
requiredSecrets: string[];
permissions: string[];
routineTriggersEnabledByDefault: boolean;
webhookSecretsRegenerated: boolean;
};
source: {
url: string;
revisionNumber: number;
manifestChecksum: string;
artifactChecksum: string;
};
}
export interface ClipImportApplyRequest extends ClipImportPreviewRequest {
selectedOptions?: Record<string, unknown>;
}
export interface ClipImportApplyResult {
importResult: Record<string, unknown>;
clip: PublicClip;
source: ClipImportPreviewResult["source"];
}
+27
View File
@@ -178,3 +178,30 @@ export const createClipImportTelemetrySchema = z.object({
});
export type CreateClipImportTelemetry = z.infer<typeof createClipImportTelemetrySchema>;
export const clipSharePreviewSchema = z.object({
source: z.object({
type: clipTypeSchema,
id: z.string().trim().min(1).max(160),
}),
title: z.string().trim().min(1).max(160).optional(),
summary: shortPublicTextSchema.optional(),
slug: slugSchema.optional(),
visibility: clipVisibilitySchema.optional(),
revisionNote: z.string().trim().max(4_000).nullable().optional(),
});
export type ClipSharePreview = z.infer<typeof clipSharePreviewSchema>;
export const clipImportPreviewSchema = z.object({
url: z.string().trim().min(1).max(2_000),
collisionStrategy: z.enum(["rename", "skip"]).optional(),
});
export type ClipImportPreview = z.infer<typeof clipImportPreviewSchema>;
export const clipImportApplySchema = clipImportPreviewSchema.extend({
selectedOptions: recordSchema.optional(),
});
export type ClipImportApply = z.infer<typeof clipImportApplySchema>;
+6
View File
@@ -47,6 +47,9 @@ export {
createClipCommentSchema,
createClipShowcaseSchema,
createClipImportTelemetrySchema,
clipSharePreviewSchema,
clipImportPreviewSchema,
clipImportApplySchema,
type CreateClipCreatorProfile,
type ClipDependencyInput,
type ClipRevisionInput,
@@ -58,6 +61,9 @@ export {
type CreateClipComment,
type CreateClipShowcase,
type CreateClipImportTelemetry,
type ClipSharePreview,
type ClipImportPreview,
type ClipImportApply,
} from "./clip.js";
export {
environmentDriverSchema,
+1 -1
View File
@@ -205,7 +205,7 @@ export async function createApp(
api.use(approvalRoutes(db, { pluginWorkerManager: workerManager }));
api.use(secretRoutes(db));
api.use(costRoutes(db, { pluginWorkerManager: workerManager }));
api.use(clipRoutes(db));
api.use(clipRoutes(db, opts.storageService));
api.use(activityRoutes(db));
api.use(dashboardRoutes(db));
api.use(userProfileRoutes(db));
+389 -3
View File
@@ -1,6 +1,15 @@
import { Router, type Request } from "express";
import type { Db } from "@paperclipai/db";
import {
buildAgentClipSnapshot,
buildBundleClipSnapshot,
buildRoutineClipSnapshot,
buildSkillClipSnapshot,
buildTeamClipSnapshot,
clipImportApplySchema,
clipImportPreviewSchema,
clipManifestSchema,
clipSharePreviewSchema,
createClipCommentSchema,
createClipCreatorProfileSchema,
createClipImportTelemetrySchema,
@@ -10,12 +19,14 @@ import {
createClipVoteSchema,
publishClipSchema,
updateClipSchema,
type CompanyPortabilityFileEntry,
} from "@paperclipai/shared";
import { validate } from "../middleware/validate.js";
import { forbidden } from "../errors.js";
import { forbidden, unprocessable } from "../errors.js";
import { assertAuthenticated, assertCompanyAccess, getActorInfo } from "./authz.js";
import { clipService } from "../services/clips.js";
import { logActivity } from "../services/index.js";
import { companyPortabilityService, companyService, logActivity } from "../services/index.js";
import type { StorageService } from "../storage/types.js";
const CLIP_RATE_LIMIT_WINDOW_MS = 60_000;
const CLIP_RATE_LIMIT_MAX_REQUESTS = 60;
@@ -72,9 +83,140 @@ function consumeClipRateLimit(req: Request, res: { setHeader(name: string, value
return true;
}
export function clipRoutes(db: Db) {
export function clipRoutes(db: Db, storage?: StorageService) {
const router = Router();
const svc = clipService(db);
const portability = companyPortabilityService(db, storage);
const companies = companyService(db);
function slugify(value: string, fallback: string) {
const slug = value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.replace(/-{2,}/g, "-")
.slice(0, 96);
return slug.length >= 3 ? slug : fallback;
}
function clipSlugFromUrl(value: string) {
const trimmed = value.trim();
if (/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(trimmed)) return trimmed;
try {
const url = new URL(trimmed);
const parts = url.pathname.split("/").filter(Boolean);
const index = parts.findIndex((part) => part === "clips");
const slug = index >= 0 ? parts[index + 1] : parts.at(-1);
if (slug && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) return slug;
} catch {
// Fall through to the validation error below.
}
throw unprocessable("Enter a clip URL or slug.");
}
function dependencyInputsFromManifest(manifest: ReturnType<typeof clipManifestSchema.parse>) {
return [
...manifest.dependencies.adapters.map((entry) => ({
type: "adapter" as const,
key: entry.type,
displayName: entry.type,
required: entry.required ? "required" as const : "optional" as const,
metadata: { sourceRefs: entry.sourceRefs, note: entry.note },
})),
...manifest.dependencies.plugins.map((entry) => ({
type: "plugin" as const,
key: entry.key,
displayName: entry.key,
required: entry.requirement,
metadata: { sourceRefs: entry.sourceRefs, note: entry.note },
})),
...manifest.dependencies.skills.map((entry) => ({
type: "skill" as const,
key: entry.key,
displayName: entry.slug,
required: entry.requirement,
metadata: { sourceRefs: entry.sourceRefs },
})),
...manifest.dependencies.secrets.map((entry) => ({
type: "secret" as const,
key: entry.key,
displayName: entry.key,
required: entry.requirement,
metadata: { sourceRefs: entry.sourceRefs, description: entry.description },
})),
...manifest.dependencies.permissions.map((entry) => ({
type: "permission" as const,
key: entry.capability,
displayName: entry.capability,
required: "required" as const,
metadata: { sourceRefs: entry.sourceRefs, reason: entry.reason },
})),
...manifest.dependencies.workspaces.map((entry) => ({
type: "workspace" as const,
key: entry.key,
displayName: entry.key,
required: entry.repoUrlRequired ? "required" as const : "optional" as const,
metadata: { sourceRefs: entry.sourceRefs, pinnedRefRecommended: entry.pinnedRefRecommended },
})),
];
}
function clipFilesFromPayload(payload: Record<string, unknown>) {
const artifact = payload.artifact;
const artifactPayload =
artifact && typeof artifact === "object"
? (artifact as Record<string, unknown>).payload
: null;
if (!artifactPayload || typeof artifactPayload !== "object") return null;
const files = (artifactPayload as Record<string, unknown>).files;
if (!files || typeof files !== "object" || Array.isArray(files)) return null;
return files as Record<string, CompanyPortabilityFileEntry>;
}
async function buildClipImportPreview(companyId: string, input: { url: string; collisionStrategy?: "rename" | "skip" }) {
const slug = clipSlugFromUrl(input.url);
const clip = await svc.getPublicDetail(slug);
if (!clip?.currentRevision) {
throw unprocessable("Clip was not found or is not importable.");
}
const manifestPayload = clip.currentRevision.manifestPayload;
const parsed = clipManifestSchema.safeParse(manifestPayload);
if (!parsed.success) {
throw unprocessable("Clip revision does not contain a valid clip manifest.");
}
const files = clipFilesFromPayload(manifestPayload);
if (!files) {
throw unprocessable("Clip revision does not include portable files. Publish it again from the app before importing.");
}
const preview = await portability.previewImport({
source: { type: "inline", rootPath: parsed.data.clip.slug, files },
include: { company: false, agents: true, projects: true, issues: true, skills: true },
target: { mode: "existing_company", companyId },
collisionStrategy: input.collisionStrategy ?? "rename",
}, {
mode: "agent_safe",
sourceCompanyId: companyId,
});
return {
clip,
parsedManifest: parsed.data,
files,
preview,
source: {
url: input.url,
revisionNumber: clip.currentRevision.revisionNumber,
manifestChecksum: clip.currentRevision.manifestChecksum,
artifactChecksum: clip.currentRevision.artifactChecksum,
},
safety: {
dangerousCapabilities: parsed.data.security.dangerousCapabilities,
requiredSecrets: parsed.data.dependencies.secrets.map((entry) => entry.key),
permissions: parsed.data.dependencies.permissions.map((entry) => entry.capability),
routineTriggersEnabledByDefault: parsed.data.security.routinePolicy.importedTriggersEnabledByDefault,
webhookSecretsRegenerated: parsed.data.security.routinePolicy.webhookSecretsRegenerated,
},
};
}
router.get("/public/clips", async (req, res) => {
const q = typeof req.query.q === "string" ? req.query.q : null;
@@ -155,6 +297,185 @@ export function clipRoutes(db: Db) {
res.status(201).json(profile);
});
router.post("/companies/:companyId/clips/share-preview", validate(clipSharePreviewSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const company = await companies.getById(companyId);
if (!company) {
res.status(404).json({ error: "Company not found" });
return;
}
const body = req.body as {
source: { type: "team" | "agent" | "skill" | "routine" | "bundle"; id: string };
title?: string;
summary?: string;
slug?: string;
visibility?: "private_share" | "unlisted" | "public";
revisionNote?: string | null;
};
const include = { company: false, agents: false, projects: false, issues: false, skills: false };
const exportRequest: {
include: typeof include;
agents?: string[];
skills?: string[];
issues?: string[];
expandReferencedSkills?: boolean;
} = { include };
if (body.source.type === "agent") {
exportRequest.include.agents = true;
exportRequest.include.skills = true;
exportRequest.agents = [body.source.id];
} else if (body.source.type === "team") {
exportRequest.include.agents = true;
exportRequest.include.skills = true;
exportRequest.agents = body.source.id === "company" ? undefined : [body.source.id];
} else if (body.source.type === "skill") {
exportRequest.include.skills = true;
exportRequest.skills = [body.source.id];
} else if (body.source.type === "routine") {
exportRequest.include.agents = true;
exportRequest.include.projects = true;
exportRequest.include.issues = true;
exportRequest.include.skills = true;
exportRequest.issues = [body.source.id];
} else {
exportRequest.include.agents = true;
exportRequest.include.projects = true;
exportRequest.include.issues = true;
exportRequest.include.skills = true;
}
exportRequest.expandReferencedSkills = true;
const exportPreview = await portability.previewExport(companyId, exportRequest);
const sourceLabel =
exportPreview.manifest.agents[0]?.name
?? exportPreview.manifest.skills[0]?.name
?? exportPreview.manifest.issues[0]?.title
?? exportPreview.manifest.company?.name
?? company.name;
const title = body.title ?? `${sourceLabel} ${body.source.type === "bundle" ? "bundle" : "clip"}`;
const summary = body.summary ?? `Portable ${body.source.type} package shared from ${company.name}.`;
const visibility = body.visibility ?? "unlisted";
const slug = body.slug ?? slugify(title, `clip-${body.source.type}`);
const creator = {
profileId: null,
handle: slugify(company.issuePrefix || company.name, "paperclip-company"),
displayName: company.name,
};
const snapshotInput = {
clip: {
slug,
revisionNumber: 1,
title,
summary,
visibility,
creator,
},
artifact: {
manifest: exportPreview.manifest,
files: exportPreview.files,
},
source: {
exportedAt: new Date().toISOString(),
paperclipCompatibility: null,
},
};
const snapshot =
body.source.type === "agent"
? buildAgentClipSnapshot(snapshotInput)
: body.source.type === "team"
? buildTeamClipSnapshot(snapshotInput)
: body.source.type === "skill"
? buildSkillClipSnapshot(snapshotInput)
: body.source.type === "routine"
? buildRoutineClipSnapshot(snapshotInput)
: buildBundleClipSnapshot(snapshotInput);
const manifestPayload = {
...snapshot,
artifact: {
...snapshot.artifact,
payload: {
...snapshot.artifact.payload,
files: exportPreview.files,
},
},
};
const parsedManifest = clipManifestSchema.parse(manifestPayload);
const publishRequest = {
creatorProfile: {
handle: creator.handle,
displayName: creator.displayName,
},
slug,
type: body.source.type,
title,
summary,
description: summary,
visibility,
tags: [body.source.type],
categories: [],
useCases: [],
requiredProviders: parsedManifest.dependencies.adapters.map((entry) => entry.type),
compatibility: parsedManifest.publication.compatibility,
sourceKind: "paperclip_company_object",
sourceObjectType: body.source.type,
sourceObjectId: body.source.id,
revision: {
manifestVersion: parsedManifest.schema,
manifestChecksum: parsedManifest.checksums.manifest,
artifactChecksum: parsedManifest.checksums.artifact,
manifestPayload,
artifactRef: null,
dependencyGraph: parsedManifest.dependencies,
dependencies: dependencyInputsFromManifest(parsedManifest),
permissions: parsedManifest.dependencies.permissions.map((entry) => ({
capability: entry.capability,
reason: entry.reason,
})),
secretsSchema: parsedManifest.dependencies.secrets.map((entry) => ({
key: entry.key,
description: entry.description,
requirement: entry.requirement,
})),
budgetEstimate: {
monthlyCents: parsedManifest.dependencies.budgetHints.monthlyCents,
},
redactionReport: parsedManifest.security.redactionReport,
dangerousCapabilities: parsedManifest.security.dangerousCapabilities,
securityReviewState: parsedManifest.security.reviewState,
verificationState: parsedManifest.verification.validationStatus,
compatibility: parsedManifest.publication.compatibility,
changeSummary: body.revisionNote ?? null,
breakingChanges: null,
migrationNotes: null,
},
};
res.json({
source: {
type: body.source.type,
id: body.source.id,
label: sourceLabel,
},
publishRequest,
exportPreview,
manifest: parsedManifest,
dependencyCounts: {
adapters: parsedManifest.dependencies.adapters.length,
plugins: parsedManifest.dependencies.plugins.length,
skills: parsedManifest.dependencies.skills.length,
secrets: parsedManifest.dependencies.secrets.length,
permissions: parsedManifest.dependencies.permissions.length,
workspaces: parsedManifest.dependencies.workspaces.length,
},
redactionSummary: parsedManifest.security.redactionReport.summary,
dangerousCapabilities: parsedManifest.security.dangerousCapabilities,
warnings: exportPreview.warnings,
});
});
router.post("/companies/:companyId/clips/publish", validate(publishClipSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
@@ -181,6 +502,71 @@ export function clipRoutes(db: Db) {
res.status(201).json(result);
});
router.post("/companies/:companyId/clips/import-preview", validate(clipImportPreviewSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const result = await buildClipImportPreview(companyId, req.body);
res.json({
clip: result.clip,
preview: result.preview,
safety: result.safety,
source: result.source,
});
});
router.post("/companies/:companyId/clips/import", validate(clipImportApplySchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const result = await buildClipImportPreview(companyId, req.body);
const importResult = await portability.importBundle({
source: { type: "inline", rootPath: result.parsedManifest.clip.slug, files: result.files },
include: { company: false, agents: true, projects: true, issues: true, skills: true },
target: { mode: "existing_company", companyId },
collisionStrategy: req.body.collisionStrategy ?? "rename",
}, req.actor.type === "board" ? req.actor.userId : null, {
mode: "agent_safe",
sourceCompanyId: companyId,
});
await svc.recordImportTelemetry(result.clip.slug, {
revisionNumber: result.source.revisionNumber,
destinationCompanyId: companyId,
status: "applied",
sourceUrl: result.source.url.startsWith("http") ? result.source.url : null,
revisionUrl: result.clip.currentRevision
? `/clips/${result.clip.slug}/revisions/${result.clip.currentRevision.revisionNumber}`
: null,
metadata: {
selectedOptions: req.body.selectedOptions ?? {},
agentCount: importResult.agents.length,
projectCount: importResult.projects.length,
warningCount: importResult.warnings.length,
},
}, actorForClip(req));
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "clip.imported",
entityType: "clip",
entityId: result.clip.id,
details: {
slug: result.clip.slug,
revisionNumber: result.source.revisionNumber,
agentCount: importResult.agents.length,
projectCount: importResult.projects.length,
warningCount: importResult.warnings.length,
},
});
res.json({
importResult,
clip: result.clip,
source: result.source,
});
});
router.post("/clips/:clipId/revisions", validate(createClipRevisionSchema), async (req, res) => {
const clip = await svc.getClipById(req.params.clipId as string);
if (!clip) {
+12
View File
@@ -123,6 +123,10 @@ export function clipService(db: Db) {
return first(await db.select().from(clipCreatorProfiles).where(eq(clipCreatorProfiles.id, id)).limit(1));
}
async function getCreatorProfileByHandle(handle: string) {
return first(await db.select().from(clipCreatorProfiles).where(eq(clipCreatorProfiles.handle, handle)).limit(1));
}
async function getClipById(id: string) {
return first(await db.select().from(clips).where(eq(clips.id, id)).limit(1));
}
@@ -252,6 +256,13 @@ export function clipService(db: Db) {
return profile;
}
if (!input.creatorProfile) throw unprocessable("creatorProfileId or creatorProfile is required");
const existing = await getCreatorProfileByHandle(input.creatorProfile.handle);
if (existing) {
if (existing.companyId !== companyId) {
throw conflict("Creator profile handle already belongs to another company");
}
return existing;
}
return createCreatorProfile(companyId, input.creatorProfile);
}
@@ -670,6 +681,7 @@ export function clipService(db: Db) {
return {
createCreatorProfile,
getCreatorProfileById,
getCreatorProfileByHandle,
getClipById,
getClipBySlug,
listPublic,
+2
View File
@@ -18,6 +18,7 @@ import { Search } from "./pages/Search";
import { IssueDetail } from "./pages/IssueDetail";
import { IssueChatLongThreadPerf } from "./pages/IssueChatLongThreadPerf";
import { Routines } from "./pages/Routines";
import { Clips } from "./pages/Clips";
import { RoutineDetail } from "./pages/RoutineDetail";
import { UserProfile } from "./pages/UserProfile";
import { ExecutionWorkspaceDetail } from "./pages/ExecutionWorkspaceDetail";
@@ -110,6 +111,7 @@ function boardRoutes() {
<Route path="tests/perf/long-thread" element={<IssueChatLongThreadPerf />} />
) : null}
<Route path="routines" element={<Routines />} />
<Route path="clips" element={<Clips />} />
<Route path="routines/:routineId" element={<RoutineDetail />} />
<Route path="execution-workspaces/:workspaceId" element={<ExecutionWorkspaceDetail />} />
<Route path="execution-workspaces/:workspaceId/services" element={<ExecutionWorkspaceDetail />} />
+44
View File
@@ -0,0 +1,44 @@
import type {
ClipImportApplyRequest,
ClipImportApplyResult,
ClipImportPreviewRequest,
ClipImportPreviewResult,
ClipSharePreviewRequest,
ClipSharePreviewResult,
PublicClip,
} from "@paperclipai/shared";
import { api } from "./client";
export const clipsApi = {
listPublic: (params: { q?: string; type?: string; tag?: string; limit?: number } = {}) => {
const searchParams = new URLSearchParams();
if (params.q) searchParams.set("q", params.q);
if (params.type) searchParams.set("type", params.type);
if (params.tag) searchParams.set("tag", params.tag);
if (params.limit) searchParams.set("limit", String(params.limit));
const qs = searchParams.toString();
return api.get<PublicClip[]>(`/public/clips${qs ? `?${qs}` : ""}`);
},
getPublic: (slug: string) =>
api.get<PublicClip>(`/public/clips/${encodeURIComponent(slug)}`),
sharePreview: (companyId: string, payload: ClipSharePreviewRequest) =>
api.post<ClipSharePreviewResult>(
`/companies/${encodeURIComponent(companyId)}/clips/share-preview`,
payload,
),
publish: (companyId: string, payload: Record<string, unknown>) =>
api.post<{ clip: PublicClip; revision: Record<string, unknown>; creatorProfile: Record<string, unknown> }>(
`/companies/${encodeURIComponent(companyId)}/clips/publish`,
payload,
),
importPreview: (companyId: string, payload: ClipImportPreviewRequest) =>
api.post<ClipImportPreviewResult>(
`/companies/${encodeURIComponent(companyId)}/clips/import-preview`,
payload,
),
importClip: (companyId: string, payload: ClipImportApplyRequest) =>
api.post<ClipImportApplyResult>(
`/companies/${encodeURIComponent(companyId)}/clips/import`,
payload,
),
};
+1
View File
@@ -18,3 +18,4 @@ export { sidebarBadgesApi } from "./sidebarBadges";
export { sidebarPreferencesApi } from "./sidebarPreferences";
export { inboxDismissalsApi } from "./inboxDismissals";
export { companySkillsApi } from "./companySkills";
export { clipsApi } from "./clips";
+2
View File
@@ -10,6 +10,7 @@ import {
Network,
Boxes,
Repeat,
Paperclip,
GitBranch,
Settings,
} from "lucide-react";
@@ -101,6 +102,7 @@ export function Sidebar() {
<SidebarSection label="Work">
<SidebarNavItem to="/issues" label="Issues" icon={CircleDot} />
<SidebarNavItem to="/routines" label="Routines" icon={Repeat} />
<SidebarNavItem to="/clips" label="Clips" icon={Paperclip} />
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
{showWorkspacesLink ? (
<SidebarNavItem to="/workspaces" label="Workspaces" icon={GitBranch} />
+6
View File
@@ -12,6 +12,12 @@ export const queryKeys = {
file: (companyId: string, skillId: string, relativePath: string) =>
["company-skills", companyId, skillId, "file", relativePath] as const,
},
clips: {
publicList: (params?: { q?: string; type?: string; tag?: string; limit?: number }) =>
["clips", "public", params ?? {}] as const,
publicDetail: (slug: string) => ["clips", "public", slug] as const,
sharePreview: (companyId: string) => ["clips", companyId, "share-preview"] as const,
},
agents: {
list: (companyId: string) => ["agents", companyId] as const,
detail: (id: string) => ["agents", "detail", id] as const,
+604
View File
@@ -0,0 +1,604 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
Check,
ClipboardCheck,
Copy,
Download,
ExternalLink,
FileSearch,
Package,
Repeat,
ShieldAlert,
Sparkles,
Upload,
} from "lucide-react";
import type {
ClipSharePreviewResult,
ClipType,
ClipVisibility,
PublicClip,
} from "@paperclipai/shared";
import { clipsApi } from "../api/clips";
import { agentsApi } from "../api/agents";
import { routinesApi } from "../api/routines";
import { companySkillsApi } from "../api/companySkills";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useCompany } from "../context/CompanyContext";
import { useToastActions } from "../context/ToastContext";
import { queryKeys } from "../lib/queryKeys";
import { cn } from "../lib/utils";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
type ShareSource = {
id: string;
type: ClipType;
label: string;
description: string;
};
const visibilityLabels: Record<ClipVisibility, string> = {
private_share: "Private link",
unlisted: "Unlisted",
public: "Public review",
};
function asPreviewRecord(value: unknown) {
return value && typeof value === "object" ? value as Record<string, unknown> : {};
}
function getPreviewPlan(preview: unknown) {
const record = asPreviewRecord(preview);
return asPreviewRecord(record.plan);
}
function getPreviewArray<T = Record<string, unknown>>(preview: unknown, key: string): T[] {
const value = getPreviewPlan(preview)[key];
return Array.isArray(value) ? value as T[] : [];
}
function formatCount(value: number, singular: string, plural = `${singular}s`) {
return `${value} ${value === 1 ? singular : plural}`;
}
function clipRevisionLabel(clip: PublicClip) {
const revision = clip.currentRevision?.revisionNumber;
return revision ? `revision ${revision}` : "no public revision";
}
function safetyTone(clip: PublicClip) {
if (clip.moderationState === "warning" || clip.moderationState === "under_review") return "text-amber-600";
if (clip.moderationState === "limited" || clip.moderationState === "blocked") return "text-destructive";
return "text-muted-foreground";
}
export function Clips() {
const { selectedCompanyId, selectedCompany } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const { pushToast } = useToastActions();
const queryClient = useQueryClient();
const [importUrl, setImportUrl] = useState("");
const [shareOpen, setShareOpen] = useState(false);
const [shareSource, setShareSource] = useState<ShareSource | null>(null);
const [shareDraft, setShareDraft] = useState({
title: "",
summary: "",
slug: "",
visibility: "unlisted" as ClipVisibility,
revisionNote: "",
});
useEffect(() => {
setBreadcrumbs([{ label: "Clips" }]);
}, [setBreadcrumbs]);
const { data: publicClips, isLoading: clipsLoading } = useQuery({
queryKey: queryKeys.clips.publicList({ limit: 18 }),
queryFn: () => clipsApi.listPublic({ limit: 18 }),
});
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: routines } = useQuery({
queryKey: queryKeys.routines.list(selectedCompanyId!),
queryFn: () => routinesApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: skills } = useQuery({
queryKey: queryKeys.companySkills.list(selectedCompanyId!),
queryFn: () => companySkillsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const shareSources = useMemo<ShareSource[]>(() => {
const sources: ShareSource[] = [];
if (selectedCompany) {
sources.push({
id: "company",
type: "bundle",
label: `${selectedCompany.name} bundle`,
description: "Share the current company as a bundled starter package.",
});
sources.push({
id: "company",
type: "team",
label: `${selectedCompany.name} team`,
description: "Share the org team and selected skills as a team clip.",
});
}
for (const agent of agents ?? []) {
sources.push({
id: agent.id,
type: "agent",
label: agent.name,
description: agent.capabilities ?? agent.title ?? agent.role,
});
}
for (const skill of skills ?? []) {
sources.push({
id: skill.id,
type: "skill",
label: skill.name,
description: skill.description ?? skill.key,
});
}
for (const routine of routines ?? []) {
sources.push({
id: routine.id,
type: "routine",
label: routine.title,
description: routine.description ?? "Recurring workflow with safe trigger review on import.",
});
}
return sources;
}, [agents, routines, selectedCompany, skills]);
const sharePreviewMutation = useMutation({
mutationFn: () => {
if (!selectedCompanyId || !shareSource) throw new Error("Select a source first.");
return clipsApi.sharePreview(selectedCompanyId, {
source: { type: shareSource.type, id: shareSource.id },
title: shareDraft.title || undefined,
summary: shareDraft.summary || undefined,
slug: shareDraft.slug || undefined,
visibility: shareDraft.visibility,
revisionNote: shareDraft.revisionNote || null,
});
},
onError: (error) => {
pushToast({
title: "Share preview failed",
body: error instanceof Error ? error.message : "Paperclip could not build the clip preview.",
tone: "error",
});
},
});
const publishMutation = useMutation({
mutationFn: (preview: ClipSharePreviewResult) => {
if (!selectedCompanyId) throw new Error("Select a company first.");
return clipsApi.publish(selectedCompanyId, preview.publishRequest);
},
onSuccess: (result) => {
pushToast({
title: "Clip published",
body: `${result.clip.title} is available as ${result.clip.visibility}.`,
tone: "success",
});
setShareOpen(false);
sharePreviewMutation.reset();
void queryClient.invalidateQueries({ queryKey: queryKeys.clips.publicList({ limit: 18 }) });
},
onError: (error) => {
pushToast({
title: "Publish failed",
body: error instanceof Error ? error.message : "Paperclip could not publish this clip.",
tone: "error",
});
},
});
const importPreviewMutation = useMutation({
mutationFn: (urlOverride?: string) => {
if (!selectedCompanyId) throw new Error("Select a company first.");
const url = (urlOverride ?? importUrl).trim();
return clipsApi.importPreview(selectedCompanyId, {
url,
collisionStrategy: "rename",
});
},
onError: (error) => {
pushToast({
title: "Import preview failed",
body: error instanceof Error ? error.message : "Paperclip could not resolve that clip.",
tone: "error",
});
},
});
const importMutation = useMutation({
mutationFn: () => {
if (!selectedCompanyId) throw new Error("Select a company first.");
return clipsApi.importClip(selectedCompanyId, {
url: importUrl.trim(),
collisionStrategy: "rename",
selectedOptions: { routineTriggers: "review_required" },
});
},
onSuccess: (result) => {
pushToast({
title: "Clip imported",
body: `${result.clip.title} was imported with routine triggers still review-gated.`,
tone: "success",
});
},
onError: (error) => {
pushToast({
title: "Import failed",
body: error instanceof Error ? error.message : "Paperclip could not import this clip.",
tone: "error",
});
},
});
function openShare(source: ShareSource) {
setShareSource(source);
setShareDraft({
title: source.label,
summary: source.description.slice(0, 240) || `Portable ${source.type} shared from Paperclip.`,
slug: source.label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80),
visibility: "unlisted",
revisionNote: "Initial app share.",
});
sharePreviewMutation.reset();
publishMutation.reset();
setShareOpen(true);
}
const activePreview = importPreviewMutation.data;
const importAgentPlans = getPreviewArray(activePreview?.preview, "agentPlans");
const importProjectPlans = getPreviewArray(activePreview?.preview, "projectPlans");
const importIssuePlans = getPreviewArray(activePreview?.preview, "issuePlans");
if (!selectedCompanyId) {
return <EmptyState icon={Package} message="Select a company to browse and import clips." />;
}
if (clipsLoading) {
return <PageSkeleton variant="list" />;
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-normal">Clips</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Publish reusable teams, agents, skills, and routines, or import a clip after a safety preview.
</p>
</div>
<Button onClick={() => shareSources[0] && openShare(shareSources[0])} disabled={shareSources.length === 0}>
<Upload className="mr-2 h-4 w-4" />
Share
</Button>
</div>
<section className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_22rem]">
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-medium">
<Download className="h-4 w-4 text-muted-foreground" />
Import from URL
</div>
<div className="rounded-md border border-border bg-card p-4">
<div className="flex flex-col gap-2 sm:flex-row">
<Input
value={importUrl}
onChange={(event) => setImportUrl(event.target.value)}
placeholder="https://paperclip.ing/clips/support-triage or support-triage"
/>
<Button
variant="outline"
onClick={() => importPreviewMutation.mutate(undefined)}
disabled={!importUrl.trim() || importPreviewMutation.isPending}
>
<FileSearch className="mr-2 h-4 w-4" />
Dry Run
</Button>
</div>
{activePreview ? (
<div className="mt-4 grid gap-4 xl:grid-cols-[minmax(0,1fr)_18rem]">
<div className="space-y-3">
<div>
<div className="text-sm font-medium">{activePreview.clip.title}</div>
<div className="mt-1 break-all text-xs text-muted-foreground">
{clipRevisionLabel(activePreview.clip)} · {activePreview.source.manifestChecksum}
</div>
</div>
<PlanRows title="Creates and updates" rows={[...importAgentPlans, ...importProjectPlans, ...importIssuePlans]} />
{(asPreviewRecord(activePreview.preview).warnings as string[] | undefined)?.length ? (
<WarningList warnings={asPreviewRecord(activePreview.preview).warnings as string[]} />
) : null}
</div>
<SafetyPanel preview={activePreview} />
<div className="xl:col-span-2 flex flex-col gap-2 border-t border-border pt-3 sm:flex-row sm:items-center sm:justify-between">
<div className="text-xs text-muted-foreground">
Import is blocked if preview errors exist. Recurring and webhook triggers stay disabled until reviewed.
</div>
<Button
onClick={() => importMutation.mutate()}
disabled={importMutation.isPending || ((asPreviewRecord(activePreview.preview).errors as unknown[] | undefined)?.length ?? 0) > 0}
>
<Check className="mr-2 h-4 w-4" />
Import Clip
</Button>
</div>
</div>
) : null}
</div>
</div>
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-medium">
<ClipboardCheck className="h-4 w-4 text-muted-foreground" />
Share from app
</div>
<div className="rounded-md border border-border bg-card">
{shareSources.slice(0, 6).map((source) => (
<button
key={`${source.type}:${source.id}`}
className="flex w-full items-start gap-3 border-b border-border px-4 py-3 text-left last:border-b-0 hover:bg-accent/40"
onClick={() => openShare(source)}
>
<SourceIcon type={source.type} />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{source.label}</span>
<span className="mt-0.5 line-clamp-2 block text-xs text-muted-foreground">{source.description}</span>
</span>
<Copy className="mt-0.5 h-3.5 w-3.5 text-muted-foreground" />
</button>
))}
</div>
</div>
</section>
<section className="space-y-3">
<div className="flex items-center gap-2 text-sm font-medium">
<Sparkles className="h-4 w-4 text-muted-foreground" />
Public catalog
</div>
{publicClips && publicClips.length > 0 ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{publicClips.map((clip) => (
<ClipCard key={clip.id} clip={clip} onImport={() => {
setImportUrl(clip.slug);
importPreviewMutation.mutate(clip.slug);
}} />
))}
</div>
) : (
<EmptyState icon={Package} message="No public clips are available yet. Publish an unlisted clip from this app to start." />
)}
</section>
<Dialog open={shareOpen} onOpenChange={setShareOpen}>
<DialogContent className="max-h-[calc(100vh-2rem)] overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Share clip</DialogTitle>
<DialogDescription>
Review dependencies, redaction, visibility, and the public preview before publishing.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_16rem]">
<div className="space-y-3">
<LabeledInput label="Title" value={shareDraft.title} onChange={(value) => setShareDraft((prev) => ({ ...prev, title: value }))} />
<LabeledInput label="Slug" value={shareDraft.slug} onChange={(value) => setShareDraft((prev) => ({ ...prev, slug: value }))} />
<label className="block space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">Summary</span>
<Textarea value={shareDraft.summary} onChange={(event) => setShareDraft((prev) => ({ ...prev, summary: event.target.value }))} />
</label>
<label className="block space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">Revision note</span>
<Textarea value={shareDraft.revisionNote} onChange={(event) => setShareDraft((prev) => ({ ...prev, revisionNote: event.target.value }))} />
</label>
</div>
<div className="space-y-3">
<label className="block space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">Visibility</span>
<select
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
value={shareDraft.visibility}
onChange={(event) => setShareDraft((prev) => ({ ...prev, visibility: event.target.value as ClipVisibility }))}
>
{Object.entries(visibilityLabels).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</label>
<Button
variant="outline"
className="w-full"
onClick={() => sharePreviewMutation.mutate()}
disabled={sharePreviewMutation.isPending || !shareSource}
>
<FileSearch className="mr-2 h-4 w-4" />
Build Preview
</Button>
{sharePreviewMutation.data ? <ShareReview preview={sharePreviewMutation.data} /> : null}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShareOpen(false)}>Cancel</Button>
<Button
onClick={() => sharePreviewMutation.data && publishMutation.mutate(sharePreviewMutation.data)}
disabled={!sharePreviewMutation.data || publishMutation.isPending}
>
<Upload className="mr-2 h-4 w-4" />
Publish
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function SourceIcon({ type }: { type: ClipType }) {
const className = "mt-0.5 h-4 w-4 shrink-0 text-muted-foreground";
if (type === "routine") return <Repeat className={className} />;
if (type === "skill") return <Sparkles className={className} />;
if (type === "agent" || type === "team") return <Package className={className} />;
return <ClipboardCheck className={className} />;
}
function LabeledInput({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) {
return (
<label className="block space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">{label}</span>
<Input value={value} onChange={(event) => onChange(event.target.value)} />
</label>
);
}
function ShareReview({ preview }: { preview: ClipSharePreviewResult }) {
return (
<div className="space-y-3 rounded-md border border-border px-3 py-3 text-sm">
<div>
<div className="font-medium">{preview.source.label}</div>
<div className="mt-0.5 text-xs text-muted-foreground">{preview.source.type} · immutable revision</div>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<Metric label="Dependencies" value={preview.dependencyCounts.adapters + preview.dependencyCounts.skills + preview.dependencyCounts.permissions} />
<Metric label="Redactions" value={preview.redactionSummary.redacted + preview.redactionSummary.omitted} />
</div>
{preview.dangerousCapabilities.length > 0 ? (
<div className="flex gap-2 text-xs text-amber-600">
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{preview.dangerousCapabilities.join(", ")}</span>
</div>
) : (
<div className="flex gap-2 text-xs text-muted-foreground">
<Check className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>No dangerous capabilities detected in the manifest preview.</span>
</div>
)}
{preview.warnings.length > 0 ? <WarningList warnings={preview.warnings.slice(0, 3)} /> : null}
</div>
);
}
function SafetyPanel({ preview }: { preview: { safety: { dangerousCapabilities: string[]; requiredSecrets: string[]; permissions: string[]; routineTriggersEnabledByDefault: boolean; webhookSecretsRegenerated: boolean } } }) {
return (
<div className="rounded-md border border-border px-3 py-3 text-sm">
<div className="flex items-center gap-2 font-medium">
<ShieldAlert className="h-4 w-4 text-muted-foreground" />
Safety review
</div>
<div className="mt-3 space-y-2 text-xs">
<SafetyRow label="Secrets" value={preview.safety.requiredSecrets.length ? preview.safety.requiredSecrets.join(", ") : "none required"} warn={preview.safety.requiredSecrets.length > 0} />
<SafetyRow label="Permissions" value={preview.safety.permissions.length ? preview.safety.permissions.join(", ") : "none requested"} warn={preview.safety.permissions.length > 0} />
<SafetyRow label="Dangerous" value={preview.safety.dangerousCapabilities.length ? preview.safety.dangerousCapabilities.join(", ") : "none detected"} warn={preview.safety.dangerousCapabilities.length > 0} />
<SafetyRow label="Routines" value={preview.safety.routineTriggersEnabledByDefault ? "triggers would enable" : "triggers disabled for review"} warn={preview.safety.routineTriggersEnabledByDefault} />
<SafetyRow label="Webhooks" value={preview.safety.webhookSecretsRegenerated ? "secrets regenerated" : "manual review required"} warn={!preview.safety.webhookSecretsRegenerated} />
</div>
</div>
);
}
function SafetyRow({ label, value, warn }: { label: string; value: string; warn: boolean }) {
return (
<div className="grid grid-cols-[5.5rem_minmax(0,1fr)] gap-2">
<span className="text-muted-foreground">{label}</span>
<span className={cn("min-w-0", warn ? "text-amber-600" : "text-foreground")}>{value}</span>
</div>
);
}
function Metric({ label, value }: { label: string; value: number }) {
return (
<div className="rounded-md border border-border px-2 py-1.5">
<div className="font-medium tabular-nums">{value}</div>
<div className="text-muted-foreground">{label}</div>
</div>
);
}
function PlanRows({ title, rows }: { title: string; rows: Record<string, unknown>[] }) {
return (
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">{title}</div>
{rows.length > 0 ? (
<div className="rounded-md border border-border">
{rows.slice(0, 8).map((row, index) => (
<div key={`${String(row.slug ?? index)}:${index}`} className="grid grid-cols-[5.5rem_minmax(0,1fr)] gap-3 border-b border-border px-3 py-2 text-sm last:border-b-0">
<span className="text-xs uppercase text-muted-foreground">{String(row.action ?? "create")}</span>
<span className="truncate">{String(row.plannedName ?? row.plannedTitle ?? row.slug ?? "Imported object")}</span>
</div>
))}
</div>
) : (
<div className="rounded-md border border-dashed border-border px-3 py-2 text-sm text-muted-foreground">
No creates or updates in this preview.
</div>
)}
</div>
);
}
function WarningList({ warnings }: { warnings: string[] }) {
return (
<div className="space-y-1.5">
{warnings.map((warning, index) => (
<div key={`${warning}:${index}`} className="flex gap-2 text-xs text-amber-600">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{warning}</span>
</div>
))}
</div>
);
}
function ClipCard({ clip, onImport }: { clip: PublicClip; onImport: () => void }) {
return (
<div className="flex min-h-[12rem] flex-col rounded-md border border-border bg-card p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{clip.title}</div>
<div className="mt-1 text-xs text-muted-foreground">
{clip.type} · @{clip.creator.handle} · {clipRevisionLabel(clip)}
</div>
</div>
<span className={cn("text-xs", safetyTone(clip))}>{clip.moderationState}</span>
</div>
<p className="mt-3 line-clamp-3 text-sm text-muted-foreground">{clip.summary}</p>
<div className="mt-4 grid grid-cols-3 gap-2 text-xs">
<Metric label="imports" value={clip.metrics.importCount} />
<Metric label="votes" value={clip.metrics.voteScore} />
<Metric label="proof" value={clip.metrics.successfulFirstRunCount} />
</div>
<div className="mt-auto flex items-center justify-between pt-4">
<span className="text-xs text-muted-foreground">
{formatCount(clip.dependencies?.length ?? 0, "dependency", "dependencies")}
</span>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={onImport}>
<FileSearch className="mr-1.5 h-3.5 w-3.5" />
Preview
</Button>
<Button asChild size="sm" variant="ghost">
<a href={`/api/public/clips/${encodeURIComponent(clip.slug)}/manifest`} target="_blank" rel="noreferrer">
<ExternalLink className="h-3.5 w-3.5" />
</a>
</Button>
</div>
</div>
</div>
);
}