create-supyagent-app 0.1.12 → 0.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +37 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/templates/base/src/app/globals.css +0 -1
- package/templates/base/src/components/ai-elements/tool.tsx +166 -0
- package/templates/base/src/components/chat-message.tsx +2 -2
- package/templates/base/src/components/supyagent/tool-message.tsx +98 -0
- package/templates/base/src/components/supyagent/tool-renderers.tsx +69 -0
- package/templates/base/src/components/supyagent/tools/brevo.tsx +140 -0
- package/templates/base/src/components/supyagent/tools/calendar.tsx +158 -0
- package/templates/base/src/components/supyagent/tools/calendly.tsx +161 -0
- package/templates/base/src/components/supyagent/tools/compute.tsx +113 -0
- package/templates/base/src/components/supyagent/tools/discord.tsx +174 -0
- package/templates/base/src/components/supyagent/tools/docs.tsx +94 -0
- package/templates/base/src/components/supyagent/tools/drive.tsx +124 -0
- package/templates/base/src/components/supyagent/tools/generic.tsx +19 -0
- package/templates/base/src/components/supyagent/tools/github.tsx +142 -0
- package/templates/base/src/components/supyagent/tools/gmail.tsx +161 -0
- package/templates/base/src/components/supyagent/tools/hubspot.tsx +206 -0
- package/templates/base/src/components/supyagent/tools/inbox.tsx +158 -0
- package/templates/base/src/components/supyagent/tools/jira.tsx +164 -0
- package/templates/base/src/components/supyagent/tools/linear.tsx +196 -0
- package/templates/base/src/components/supyagent/tools/linkedin.tsx +151 -0
- package/templates/base/src/components/supyagent/tools/notion.tsx +223 -0
- package/templates/base/src/components/supyagent/tools/pipedrive.tsx +154 -0
- package/templates/base/src/components/supyagent/tools/resend.tsx +92 -0
- package/templates/base/src/components/supyagent/tools/salesforce.tsx +163 -0
- package/templates/base/src/components/supyagent/tools/search.tsx +141 -0
- package/templates/base/src/components/supyagent/tools/sheets.tsx +109 -0
- package/templates/base/src/components/supyagent/tools/slack.tsx +135 -0
- package/templates/base/src/components/supyagent/tools/slides.tsx +64 -0
- package/templates/base/src/components/supyagent/tools/stripe.tsx +235 -0
- package/templates/base/src/components/supyagent/tools/telegram.tsx +116 -0
- package/templates/base/src/components/supyagent/tools/twilio.tsx +118 -0
- package/templates/base/src/components/supyagent/tools/twitter.tsx +139 -0
- package/templates/base/src/components/ui/badge.tsx +35 -0
- package/templates/base/src/components/ui/collapsible.tsx +11 -0
- package/templates/package-json/package.json.tmpl +3 -1
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { MessageSquare, Hash, Shield, Users } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
interface GuildData {
|
|
5
|
+
id?: string;
|
|
6
|
+
name?: string;
|
|
7
|
+
owner?: boolean;
|
|
8
|
+
member_count?: number;
|
|
9
|
+
icon?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface ChannelData {
|
|
13
|
+
id?: string;
|
|
14
|
+
name?: string;
|
|
15
|
+
type?: number;
|
|
16
|
+
position?: number;
|
|
17
|
+
member_count?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface MessageData {
|
|
21
|
+
id?: string;
|
|
22
|
+
content?: string;
|
|
23
|
+
author?: { username?: string; id?: string };
|
|
24
|
+
timestamp?: string;
|
|
25
|
+
channel_id?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface DiscordRendererProps {
|
|
29
|
+
data: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isGuildData(data: unknown): data is GuildData {
|
|
33
|
+
return typeof data === "object" && data !== null && "name" in data && ("owner" in data || "member_count" in data || "icon" in data);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isChannelData(data: unknown): data is ChannelData {
|
|
37
|
+
return typeof data === "object" && data !== null && "name" in data && ("type" in data || "position" in data);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isMessageData(data: unknown): data is MessageData {
|
|
41
|
+
return typeof data === "object" && data !== null && ("content" in data || "author" in data) && "id" in data;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function formatRelativeDate(dateStr: string): string {
|
|
45
|
+
try {
|
|
46
|
+
const date = new Date(dateStr);
|
|
47
|
+
const now = new Date();
|
|
48
|
+
const diffMs = now.getTime() - date.getTime();
|
|
49
|
+
const diffMins = Math.floor(diffMs / 60000);
|
|
50
|
+
const diffHours = Math.floor(diffMs / 3600000);
|
|
51
|
+
const diffDays = Math.floor(diffMs / 86400000);
|
|
52
|
+
if (diffMins < 1) return "Just now";
|
|
53
|
+
if (diffMins < 60) return `${diffMins}m ago`;
|
|
54
|
+
if (diffHours < 24) return `${diffHours}h ago`;
|
|
55
|
+
if (diffDays < 7) return `${diffDays}d ago`;
|
|
56
|
+
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
|
57
|
+
} catch {
|
|
58
|
+
return dateStr;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function GuildCard({ guild }: { guild: GuildData }) {
|
|
63
|
+
return (
|
|
64
|
+
<div className="rounded-lg border border-border bg-card p-3 space-y-1">
|
|
65
|
+
<div className="flex items-center gap-2">
|
|
66
|
+
<Shield className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
67
|
+
<span className="text-sm font-medium text-foreground flex-1">{guild.name}</span>
|
|
68
|
+
{guild.owner && (
|
|
69
|
+
<span className="rounded-full bg-yellow-500/10 px-2 py-0.5 text-xs text-yellow-600">Owner</span>
|
|
70
|
+
)}
|
|
71
|
+
</div>
|
|
72
|
+
{guild.member_count !== undefined && (
|
|
73
|
+
<div className="flex items-center gap-1 pl-6 text-xs text-muted-foreground">
|
|
74
|
+
<Users className="h-3 w-3" />
|
|
75
|
+
{guild.member_count} members
|
|
76
|
+
</div>
|
|
77
|
+
)}
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function ChannelCard({ channel }: { channel: ChannelData }) {
|
|
83
|
+
return (
|
|
84
|
+
<div className="flex items-center gap-2 rounded-lg border border-border bg-card p-3">
|
|
85
|
+
<Hash className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
86
|
+
<span className="text-sm text-foreground flex-1">{channel.name || channel.id}</span>
|
|
87
|
+
{channel.member_count !== undefined && (
|
|
88
|
+
<span className="text-xs text-muted-foreground">{channel.member_count} members</span>
|
|
89
|
+
)}
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function MessageCard({ message }: { message: MessageData }) {
|
|
95
|
+
return (
|
|
96
|
+
<div className="rounded-lg border border-border bg-card p-3 space-y-1.5">
|
|
97
|
+
<div className="flex items-center gap-2">
|
|
98
|
+
<MessageSquare className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
99
|
+
{message.author?.username && (
|
|
100
|
+
<span className="text-xs font-medium text-foreground">{message.author.username}</span>
|
|
101
|
+
)}
|
|
102
|
+
{message.timestamp && (
|
|
103
|
+
<span className="text-xs text-muted-foreground ml-auto">{formatRelativeDate(message.timestamp)}</span>
|
|
104
|
+
)}
|
|
105
|
+
</div>
|
|
106
|
+
{message.content && (
|
|
107
|
+
<p className="text-sm text-foreground line-clamp-3">{message.content}</p>
|
|
108
|
+
)}
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function DiscordRenderer({ data }: DiscordRendererProps) {
|
|
114
|
+
// Guilds
|
|
115
|
+
if (typeof data === "object" && data !== null && "guilds" in data) {
|
|
116
|
+
const guilds = (data as any).guilds;
|
|
117
|
+
if (Array.isArray(guilds)) {
|
|
118
|
+
return (
|
|
119
|
+
<div className="space-y-1.5">
|
|
120
|
+
{guilds.filter(isGuildData).map((g, i) => <GuildCard key={g.id || i} guild={g} />)}
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Channels
|
|
127
|
+
if (typeof data === "object" && data !== null && "channels" in data) {
|
|
128
|
+
const channels = (data as any).channels;
|
|
129
|
+
if (Array.isArray(channels)) {
|
|
130
|
+
return (
|
|
131
|
+
<div className="space-y-1.5">
|
|
132
|
+
{channels.filter(isChannelData).map((c, i) => <ChannelCard key={c.id || i} channel={c} />)}
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Messages
|
|
139
|
+
if (typeof data === "object" && data !== null && "messages" in data) {
|
|
140
|
+
const messages = (data as any).messages;
|
|
141
|
+
if (Array.isArray(messages)) {
|
|
142
|
+
return (
|
|
143
|
+
<div className="space-y-2">
|
|
144
|
+
{messages.filter(isMessageData).map((m, i) => <MessageCard key={m.id || i} message={m} />)}
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Single message
|
|
151
|
+
if (isMessageData(data)) return <MessageCard message={data} />;
|
|
152
|
+
|
|
153
|
+
// Array
|
|
154
|
+
if (Array.isArray(data)) {
|
|
155
|
+
const guilds = data.filter(isGuildData);
|
|
156
|
+
if (guilds.length > 0) {
|
|
157
|
+
return (
|
|
158
|
+
<div className="space-y-1.5">{guilds.map((g, i) => <GuildCard key={g.id || i} guild={g} />)}</div>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
const messages = data.filter(isMessageData);
|
|
162
|
+
if (messages.length > 0) {
|
|
163
|
+
return (
|
|
164
|
+
<div className="space-y-2">{messages.map((m, i) => <MessageCard key={m.id || i} message={m} />)}</div>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<pre className="rounded-lg border border-border bg-background p-3 text-xs text-foreground overflow-x-auto max-h-96 overflow-y-auto">
|
|
171
|
+
{JSON.stringify(data, null, 2)}
|
|
172
|
+
</pre>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { FileText, ExternalLink } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
interface DocData {
|
|
5
|
+
documentId?: string;
|
|
6
|
+
title?: string;
|
|
7
|
+
body?: { content?: unknown };
|
|
8
|
+
revisionId?: string;
|
|
9
|
+
url?: string;
|
|
10
|
+
content?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface DocsRendererProps {
|
|
14
|
+
data: unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isDocData(data: unknown): data is DocData {
|
|
18
|
+
return typeof data === "object" && data !== null && ("documentId" in data || "title" in data);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function extractTextContent(body: unknown): string | null {
|
|
22
|
+
if (!body || typeof body !== "object") return null;
|
|
23
|
+
const content = (body as any).content;
|
|
24
|
+
if (!Array.isArray(content)) return null;
|
|
25
|
+
|
|
26
|
+
const texts: string[] = [];
|
|
27
|
+
for (const element of content) {
|
|
28
|
+
if (element?.paragraph?.elements) {
|
|
29
|
+
for (const el of element.paragraph.elements) {
|
|
30
|
+
if (el?.textRun?.content) {
|
|
31
|
+
texts.push(el.textRun.content);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return texts.length > 0 ? texts.join("").trim() : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function DocCard({ doc }: { doc: DocData }) {
|
|
40
|
+
const docUrl = doc.url || (doc.documentId ? `https://docs.google.com/document/d/${doc.documentId}` : null);
|
|
41
|
+
const textContent = doc.content || extractTextContent(doc.body);
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div className="rounded-lg border border-border bg-card p-3 space-y-2">
|
|
45
|
+
<div className="flex items-start gap-2">
|
|
46
|
+
<FileText className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
|
|
47
|
+
<div className="min-w-0 flex-1">
|
|
48
|
+
<p className="text-sm font-medium text-foreground">
|
|
49
|
+
{docUrl ? (
|
|
50
|
+
<a href={docUrl} target="_blank" rel="noopener noreferrer" className="hover:underline">
|
|
51
|
+
{doc.title || "Untitled document"}
|
|
52
|
+
</a>
|
|
53
|
+
) : (
|
|
54
|
+
doc.title || "Untitled document"
|
|
55
|
+
)}
|
|
56
|
+
</p>
|
|
57
|
+
</div>
|
|
58
|
+
{docUrl && (
|
|
59
|
+
<a href={docUrl} target="_blank" rel="noopener noreferrer" className="shrink-0 text-muted-foreground hover:text-foreground">
|
|
60
|
+
<ExternalLink className="h-3.5 w-3.5" />
|
|
61
|
+
</a>
|
|
62
|
+
)}
|
|
63
|
+
</div>
|
|
64
|
+
{textContent && (
|
|
65
|
+
<p className="text-xs text-muted-foreground line-clamp-4 whitespace-pre-wrap">{textContent}</p>
|
|
66
|
+
)}
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function DocsRenderer({ data }: DocsRendererProps) {
|
|
72
|
+
if (isDocData(data)) {
|
|
73
|
+
return <DocCard doc={data} />;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (Array.isArray(data)) {
|
|
77
|
+
const docs = data.filter(isDocData);
|
|
78
|
+
if (docs.length > 0) {
|
|
79
|
+
return (
|
|
80
|
+
<div className="space-y-2">
|
|
81
|
+
{docs.map((doc, i) => (
|
|
82
|
+
<DocCard key={doc.documentId || i} doc={doc} />
|
|
83
|
+
))}
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<pre className="rounded-lg border border-border bg-background p-3 text-xs text-foreground overflow-x-auto max-h-96 overflow-y-auto">
|
|
91
|
+
{JSON.stringify(data, null, 2)}
|
|
92
|
+
</pre>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { FileText, Folder, Image, Film, FileSpreadsheet, ExternalLink, Users as UsersIcon } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
interface DriveFileData {
|
|
5
|
+
id?: string;
|
|
6
|
+
name?: string;
|
|
7
|
+
mimeType?: string;
|
|
8
|
+
modifiedTime?: string;
|
|
9
|
+
size?: string | number;
|
|
10
|
+
webViewLink?: string;
|
|
11
|
+
shared?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface DriveRendererProps {
|
|
15
|
+
data: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isDriveFile(data: unknown): data is DriveFileData {
|
|
19
|
+
return typeof data === "object" && data !== null && ("name" in data || "mimeType" in data);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function getFileIcon(mimeType?: string) {
|
|
23
|
+
if (!mimeType) return FileText;
|
|
24
|
+
if (mimeType.includes("folder")) return Folder;
|
|
25
|
+
if (mimeType.includes("image")) return Image;
|
|
26
|
+
if (mimeType.includes("video")) return Film;
|
|
27
|
+
if (mimeType.includes("spreadsheet") || mimeType.includes("excel")) return FileSpreadsheet;
|
|
28
|
+
return FileText;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function formatFileSize(size: string | number | undefined): string | null {
|
|
32
|
+
if (size === undefined) return null;
|
|
33
|
+
const bytes = typeof size === "string" ? parseInt(size, 10) : size;
|
|
34
|
+
if (isNaN(bytes)) return null;
|
|
35
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
36
|
+
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
37
|
+
if (bytes < 1073741824) return `${(bytes / 1048576).toFixed(1)} MB`;
|
|
38
|
+
return `${(bytes / 1073741824).toFixed(1)} GB`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function FileCard({ file }: { file: DriveFileData }) {
|
|
42
|
+
const Icon = getFileIcon(file.mimeType);
|
|
43
|
+
const sizeStr = formatFileSize(file.size);
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div className="flex items-center gap-3 rounded-lg border border-border bg-card p-3">
|
|
47
|
+
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
48
|
+
<div className="min-w-0 flex-1">
|
|
49
|
+
<p className="text-sm text-foreground truncate">
|
|
50
|
+
{file.webViewLink ? (
|
|
51
|
+
<a href={file.webViewLink} target="_blank" rel="noopener noreferrer" className="hover:underline">
|
|
52
|
+
{file.name || "Untitled"}
|
|
53
|
+
</a>
|
|
54
|
+
) : (
|
|
55
|
+
file.name || "Untitled"
|
|
56
|
+
)}
|
|
57
|
+
</p>
|
|
58
|
+
<div className="flex items-center gap-2">
|
|
59
|
+
{file.modifiedTime && (
|
|
60
|
+
<span className="text-xs text-muted-foreground">
|
|
61
|
+
{new Date(file.modifiedTime).toLocaleDateString()}
|
|
62
|
+
</span>
|
|
63
|
+
)}
|
|
64
|
+
{sizeStr && (
|
|
65
|
+
<span className="text-xs text-muted-foreground">{sizeStr}</span>
|
|
66
|
+
)}
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
<div className="flex items-center gap-1.5 shrink-0">
|
|
70
|
+
{file.shared && (
|
|
71
|
+
<span title="Shared">
|
|
72
|
+
<UsersIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
|
73
|
+
</span>
|
|
74
|
+
)}
|
|
75
|
+
{file.webViewLink && (
|
|
76
|
+
<a href={file.webViewLink} target="_blank" rel="noopener noreferrer" className="text-muted-foreground hover:text-foreground">
|
|
77
|
+
<ExternalLink className="h-3.5 w-3.5" />
|
|
78
|
+
</a>
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function DriveRenderer({ data }: DriveRendererProps) {
|
|
86
|
+
if (isDriveFile(data)) {
|
|
87
|
+
return <FileCard file={data} />;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (Array.isArray(data)) {
|
|
91
|
+
const files = data.filter(isDriveFile);
|
|
92
|
+
if (files.length > 0) {
|
|
93
|
+
return (
|
|
94
|
+
<div className="space-y-1.5">
|
|
95
|
+
{files.map((file, i) => (
|
|
96
|
+
<FileCard key={file.id || i} file={file} />
|
|
97
|
+
))}
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (typeof data === "object" && data !== null && "files" in data) {
|
|
104
|
+
const files = (data as { files: unknown[] }).files;
|
|
105
|
+
if (Array.isArray(files)) {
|
|
106
|
+
const driveFiles = files.filter(isDriveFile);
|
|
107
|
+
if (driveFiles.length > 0) {
|
|
108
|
+
return (
|
|
109
|
+
<div className="space-y-1.5">
|
|
110
|
+
{driveFiles.map((file, i) => (
|
|
111
|
+
<FileCard key={file.id || i} file={file} />
|
|
112
|
+
))}
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<pre className="rounded-lg border border-border bg-background p-3 text-xs text-foreground overflow-x-auto max-h-96 overflow-y-auto">
|
|
121
|
+
{JSON.stringify(data, null, 2)}
|
|
122
|
+
</pre>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
interface GenericRendererProps {
|
|
4
|
+
data: unknown;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function GenericRenderer({ data }: GenericRendererProps) {
|
|
8
|
+
if (data === null || data === undefined) {
|
|
9
|
+
return (
|
|
10
|
+
<p className="text-sm text-muted-foreground italic">No data returned</p>
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<pre className="rounded-lg border border-border bg-background p-3 text-xs text-foreground overflow-x-auto max-h-96 overflow-y-auto">
|
|
16
|
+
{JSON.stringify(data, null, 2)}
|
|
17
|
+
</pre>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { CircleDot, GitPullRequest, GitMerge, Clock } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
interface GithubItemData {
|
|
5
|
+
title?: string;
|
|
6
|
+
number?: number;
|
|
7
|
+
state?: string;
|
|
8
|
+
html_url?: string;
|
|
9
|
+
body?: string;
|
|
10
|
+
user?: { login?: string };
|
|
11
|
+
created_at?: string;
|
|
12
|
+
pull_request?: unknown;
|
|
13
|
+
merged?: boolean;
|
|
14
|
+
labels?: Array<{ name: string; color?: string }>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface GitHubRendererProps {
|
|
18
|
+
data: unknown;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isGithubItem(data: unknown): data is GithubItemData {
|
|
22
|
+
return typeof data === "object" && data !== null && ("title" in data || "number" in data);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function formatRelativeDate(dateStr: string): string {
|
|
26
|
+
try {
|
|
27
|
+
const date = new Date(dateStr);
|
|
28
|
+
const now = new Date();
|
|
29
|
+
const diffMs = now.getTime() - date.getTime();
|
|
30
|
+
const diffDays = Math.floor(diffMs / 86400000);
|
|
31
|
+
|
|
32
|
+
if (diffDays === 0) return "today";
|
|
33
|
+
if (diffDays === 1) return "yesterday";
|
|
34
|
+
if (diffDays < 30) return `${diffDays}d ago`;
|
|
35
|
+
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
|
36
|
+
} catch {
|
|
37
|
+
return dateStr;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getStateInfo(item: GithubItemData) {
|
|
42
|
+
const isPR = !!item.pull_request;
|
|
43
|
+
|
|
44
|
+
if (isPR && item.merged) {
|
|
45
|
+
return { icon: GitMerge, color: "text-purple-500", label: "Merged" };
|
|
46
|
+
}
|
|
47
|
+
if (item.state === "open") {
|
|
48
|
+
return {
|
|
49
|
+
icon: isPR ? GitPullRequest : CircleDot,
|
|
50
|
+
color: "text-green-500",
|
|
51
|
+
label: "Open",
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
icon: isPR ? GitPullRequest : CircleDot,
|
|
56
|
+
color: "text-destructive",
|
|
57
|
+
label: "Closed",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function GithubCard({ item }: { item: GithubItemData }) {
|
|
62
|
+
const { icon: StateIcon, color: stateColor, label: stateLabel } = getStateInfo(item);
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div className="rounded-lg border border-border bg-card p-3 space-y-1.5">
|
|
66
|
+
<div className="flex items-start gap-2">
|
|
67
|
+
<StateIcon className={`h-4 w-4 mt-0.5 shrink-0 ${stateColor}`} />
|
|
68
|
+
<div className="min-w-0 flex-1">
|
|
69
|
+
<p className="text-sm font-medium text-foreground">
|
|
70
|
+
{item.html_url ? (
|
|
71
|
+
<a href={item.html_url} target="_blank" rel="noopener noreferrer" className="hover:underline">
|
|
72
|
+
{item.title}
|
|
73
|
+
</a>
|
|
74
|
+
) : (
|
|
75
|
+
item.title
|
|
76
|
+
)}
|
|
77
|
+
{item.number && (
|
|
78
|
+
<span className="text-muted-foreground font-normal"> #{item.number}</span>
|
|
79
|
+
)}
|
|
80
|
+
</p>
|
|
81
|
+
<div className="flex items-center gap-2 mt-0.5">
|
|
82
|
+
<span className={`text-xs ${stateColor}`}>{stateLabel}</span>
|
|
83
|
+
{item.user?.login && (
|
|
84
|
+
<span className="text-xs text-muted-foreground">{item.user.login}</span>
|
|
85
|
+
)}
|
|
86
|
+
{item.created_at && (
|
|
87
|
+
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
88
|
+
<Clock className="h-3 w-3" />
|
|
89
|
+
{formatRelativeDate(item.created_at)}
|
|
90
|
+
</span>
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
{item.labels && item.labels.length > 0 && (
|
|
96
|
+
<div className="flex flex-wrap gap-1.5">
|
|
97
|
+
{item.labels.map((label) => (
|
|
98
|
+
<span
|
|
99
|
+
key={label.name}
|
|
100
|
+
className="rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground"
|
|
101
|
+
style={
|
|
102
|
+
label.color
|
|
103
|
+
? { borderColor: `#${label.color}`, color: `#${label.color}` }
|
|
104
|
+
: undefined
|
|
105
|
+
}
|
|
106
|
+
>
|
|
107
|
+
{label.name}
|
|
108
|
+
</span>
|
|
109
|
+
))}
|
|
110
|
+
</div>
|
|
111
|
+
)}
|
|
112
|
+
{item.body && (
|
|
113
|
+
<p className="text-xs text-muted-foreground line-clamp-2">{item.body}</p>
|
|
114
|
+
)}
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function GitHubRenderer({ data }: GitHubRendererProps) {
|
|
120
|
+
if (isGithubItem(data)) {
|
|
121
|
+
return <GithubCard item={data} />;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (Array.isArray(data)) {
|
|
125
|
+
const items = data.filter(isGithubItem);
|
|
126
|
+
if (items.length > 0) {
|
|
127
|
+
return (
|
|
128
|
+
<div className="space-y-2">
|
|
129
|
+
{items.map((item, i) => (
|
|
130
|
+
<GithubCard key={item.number || i} item={item} />
|
|
131
|
+
))}
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<pre className="rounded-lg border border-border bg-background p-3 text-xs text-foreground overflow-x-auto max-h-96 overflow-y-auto">
|
|
139
|
+
{JSON.stringify(data, null, 2)}
|
|
140
|
+
</pre>
|
|
141
|
+
);
|
|
142
|
+
}
|