create-supyagent-app 0.1.13 → 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,196 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { CircleDot, FolderKanban } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
interface LinearIssueData {
|
|
5
|
+
id?: string;
|
|
6
|
+
title?: string;
|
|
7
|
+
identifier?: string;
|
|
8
|
+
state?: { name?: string; color?: string; type?: string };
|
|
9
|
+
priority?: number;
|
|
10
|
+
priorityLabel?: string;
|
|
11
|
+
assignee?: { name?: string; displayName?: string; email?: string };
|
|
12
|
+
url?: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface LinearProjectData {
|
|
17
|
+
id?: string;
|
|
18
|
+
name?: string;
|
|
19
|
+
state?: string;
|
|
20
|
+
progress?: number;
|
|
21
|
+
url?: string;
|
|
22
|
+
description?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface LinearRendererProps {
|
|
26
|
+
data: unknown;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isLinearIssue(data: unknown): data is LinearIssueData {
|
|
30
|
+
if (typeof data !== "object" || data === null) return false;
|
|
31
|
+
return ("title" in data && ("identifier" in data || "state" in data || "priority" in data));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isLinearProject(data: unknown): data is LinearProjectData {
|
|
35
|
+
if (typeof data !== "object" || data === null) return false;
|
|
36
|
+
return "name" in data && ("progress" in data || ("state" in data && typeof (data as any).state === "string"));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const PRIORITY_LABELS: Record<number, { label: string; style: string }> = {
|
|
40
|
+
0: { label: "No priority", style: "text-muted-foreground" },
|
|
41
|
+
1: { label: "Urgent", style: "text-destructive" },
|
|
42
|
+
2: { label: "High", style: "text-orange-500" },
|
|
43
|
+
3: { label: "Medium", style: "text-yellow-500" },
|
|
44
|
+
4: { label: "Low", style: "text-muted-foreground" },
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function IssueCard({ issue }: { issue: LinearIssueData }) {
|
|
48
|
+
const priorityInfo = issue.priority !== undefined ? PRIORITY_LABELS[issue.priority] : null;
|
|
49
|
+
const assigneeName = issue.assignee?.displayName || issue.assignee?.name;
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className="rounded-lg border border-border bg-card p-3 space-y-1.5">
|
|
53
|
+
<div className="flex items-start gap-2">
|
|
54
|
+
<CircleDot
|
|
55
|
+
className="h-4 w-4 mt-0.5 shrink-0"
|
|
56
|
+
style={issue.state?.color ? { color: issue.state.color } : undefined}
|
|
57
|
+
/>
|
|
58
|
+
<div className="min-w-0 flex-1">
|
|
59
|
+
<p className="text-sm font-medium text-foreground">
|
|
60
|
+
{issue.url ? (
|
|
61
|
+
<a href={issue.url} target="_blank" rel="noopener noreferrer" className="hover:underline">
|
|
62
|
+
{issue.title}
|
|
63
|
+
</a>
|
|
64
|
+
) : (
|
|
65
|
+
issue.title
|
|
66
|
+
)}
|
|
67
|
+
{issue.identifier && (
|
|
68
|
+
<span className="text-muted-foreground font-normal"> {issue.identifier}</span>
|
|
69
|
+
)}
|
|
70
|
+
</p>
|
|
71
|
+
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
|
72
|
+
{issue.state?.name && (
|
|
73
|
+
<span
|
|
74
|
+
className="rounded-full bg-muted px-2 py-0.5 text-xs"
|
|
75
|
+
style={issue.state.color ? { color: issue.state.color } : undefined}
|
|
76
|
+
>
|
|
77
|
+
{issue.state.name}
|
|
78
|
+
</span>
|
|
79
|
+
)}
|
|
80
|
+
{priorityInfo && (
|
|
81
|
+
<span className={`text-xs ${priorityInfo.style}`}>
|
|
82
|
+
{issue.priorityLabel || priorityInfo.label}
|
|
83
|
+
</span>
|
|
84
|
+
)}
|
|
85
|
+
{assigneeName && (
|
|
86
|
+
<span className="text-xs text-muted-foreground">{assigneeName}</span>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function ProjectCard({ project }: { project: LinearProjectData }) {
|
|
96
|
+
const progressPercent = project.progress !== undefined ? Math.round(project.progress * 100) : null;
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div className="rounded-lg border border-border bg-card p-3 space-y-2">
|
|
100
|
+
<div className="flex items-start gap-2">
|
|
101
|
+
<FolderKanban className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
|
|
102
|
+
<div className="min-w-0 flex-1">
|
|
103
|
+
<p className="text-sm font-medium text-foreground">
|
|
104
|
+
{project.url ? (
|
|
105
|
+
<a href={project.url} target="_blank" rel="noopener noreferrer" className="hover:underline">
|
|
106
|
+
{project.name}
|
|
107
|
+
</a>
|
|
108
|
+
) : (
|
|
109
|
+
project.name
|
|
110
|
+
)}
|
|
111
|
+
</p>
|
|
112
|
+
{project.state && (
|
|
113
|
+
<span className="text-xs text-muted-foreground">{project.state}</span>
|
|
114
|
+
)}
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
{progressPercent !== null && (
|
|
118
|
+
<div className="space-y-1">
|
|
119
|
+
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
|
|
120
|
+
<div
|
|
121
|
+
className="h-full rounded-full bg-primary transition-all"
|
|
122
|
+
style={{ width: `${progressPercent}%` }}
|
|
123
|
+
/>
|
|
124
|
+
</div>
|
|
125
|
+
<p className="text-xs text-muted-foreground text-right">{progressPercent}%</p>
|
|
126
|
+
</div>
|
|
127
|
+
)}
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function LinearRenderer({ data }: LinearRendererProps) {
|
|
133
|
+
if (isLinearIssue(data)) {
|
|
134
|
+
return <IssueCard issue={data} />;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (isLinearProject(data)) {
|
|
138
|
+
return <ProjectCard project={data} />;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (Array.isArray(data)) {
|
|
142
|
+
const issues = data.filter(isLinearIssue);
|
|
143
|
+
if (issues.length > 0) {
|
|
144
|
+
return (
|
|
145
|
+
<div className="space-y-2">
|
|
146
|
+
{issues.map((issue, i) => (
|
|
147
|
+
<IssueCard key={issue.id || i} issue={issue} />
|
|
148
|
+
))}
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
const projects = data.filter(isLinearProject);
|
|
153
|
+
if (projects.length > 0) {
|
|
154
|
+
return (
|
|
155
|
+
<div className="space-y-2">
|
|
156
|
+
{projects.map((project, i) => (
|
|
157
|
+
<ProjectCard key={project.id || i} project={project} />
|
|
158
|
+
))}
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Object with nodes/issues array (Linear API pattern)
|
|
165
|
+
if (typeof data === "object" && data !== null) {
|
|
166
|
+
const arr = (data as any).nodes || (data as any).issues || (data as any).projects;
|
|
167
|
+
if (Array.isArray(arr)) {
|
|
168
|
+
const issues = arr.filter(isLinearIssue);
|
|
169
|
+
if (issues.length > 0) {
|
|
170
|
+
return (
|
|
171
|
+
<div className="space-y-2">
|
|
172
|
+
{issues.map((issue, i) => (
|
|
173
|
+
<IssueCard key={issue.id || i} issue={issue} />
|
|
174
|
+
))}
|
|
175
|
+
</div>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
const projects = arr.filter(isLinearProject);
|
|
179
|
+
if (projects.length > 0) {
|
|
180
|
+
return (
|
|
181
|
+
<div className="space-y-2">
|
|
182
|
+
{projects.map((project, i) => (
|
|
183
|
+
<ProjectCard key={project.id || i} project={project} />
|
|
184
|
+
))}
|
|
185
|
+
</div>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return (
|
|
192
|
+
<pre className="rounded-lg border border-border bg-background p-3 text-xs text-foreground overflow-x-auto max-h-96 overflow-y-auto">
|
|
193
|
+
{JSON.stringify(data, null, 2)}
|
|
194
|
+
</pre>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { User, FileText, ExternalLink, Clock } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
interface LinkedInProfileData {
|
|
5
|
+
id?: string;
|
|
6
|
+
localizedFirstName?: string;
|
|
7
|
+
localizedLastName?: string;
|
|
8
|
+
firstName?: string;
|
|
9
|
+
lastName?: string;
|
|
10
|
+
name?: string;
|
|
11
|
+
headline?: string;
|
|
12
|
+
vanityName?: string;
|
|
13
|
+
profilePicture?: unknown;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface LinkedInPostData {
|
|
17
|
+
id?: string;
|
|
18
|
+
text?: string;
|
|
19
|
+
commentary?: string;
|
|
20
|
+
created?: { time?: number };
|
|
21
|
+
timestamp?: string;
|
|
22
|
+
author?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface LinkedInRendererProps {
|
|
26
|
+
data: unknown;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isProfile(data: unknown): data is LinkedInProfileData {
|
|
30
|
+
if (typeof data !== "object" || data === null) return false;
|
|
31
|
+
return "localizedFirstName" in data || "firstName" in data || ("name" in data && "headline" in data);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isPost(data: unknown): data is LinkedInPostData {
|
|
35
|
+
if (typeof data !== "object" || data === null) return false;
|
|
36
|
+
return ("text" in data || "commentary" in data) && !("localizedFirstName" in data);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function formatRelativeDate(dateStr: string | number): string {
|
|
40
|
+
try {
|
|
41
|
+
const date = typeof dateStr === "number" ? new Date(dateStr) : new Date(dateStr);
|
|
42
|
+
const now = new Date();
|
|
43
|
+
const diffMs = now.getTime() - date.getTime();
|
|
44
|
+
const diffDays = Math.floor(diffMs / 86400000);
|
|
45
|
+
if (diffDays === 0) return "today";
|
|
46
|
+
if (diffDays === 1) return "yesterday";
|
|
47
|
+
if (diffDays < 7) return `${diffDays}d ago`;
|
|
48
|
+
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
|
49
|
+
} catch {
|
|
50
|
+
return String(dateStr);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function ProfileCard({ profile }: { profile: LinkedInProfileData }) {
|
|
55
|
+
const name = profile.name ||
|
|
56
|
+
[profile.localizedFirstName || profile.firstName, profile.localizedLastName || profile.lastName]
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
.join(" ");
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div className="rounded-lg border border-border bg-card p-3 space-y-1">
|
|
62
|
+
<div className="flex items-start gap-2">
|
|
63
|
+
<User className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
|
|
64
|
+
<div className="min-w-0 flex-1">
|
|
65
|
+
<p className="text-sm font-medium text-foreground">{name || "LinkedIn Profile"}</p>
|
|
66
|
+
{profile.headline && (
|
|
67
|
+
<p className="text-xs text-muted-foreground line-clamp-2">{profile.headline}</p>
|
|
68
|
+
)}
|
|
69
|
+
{profile.vanityName && (
|
|
70
|
+
<p className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
71
|
+
<ExternalLink className="h-3 w-3" />
|
|
72
|
+
linkedin.com/in/{profile.vanityName}
|
|
73
|
+
</p>
|
|
74
|
+
)}
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function PostCard({ post }: { post: LinkedInPostData }) {
|
|
82
|
+
const content = post.text || post.commentary;
|
|
83
|
+
const time = post.created?.time || post.timestamp;
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<div className="rounded-lg border border-border bg-card p-3 space-y-1.5">
|
|
87
|
+
<div className="flex items-center gap-2">
|
|
88
|
+
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
89
|
+
{post.author && (
|
|
90
|
+
<span className="text-xs font-medium text-foreground">{post.author}</span>
|
|
91
|
+
)}
|
|
92
|
+
{time && (
|
|
93
|
+
<span className="flex items-center gap-1 text-xs text-muted-foreground ml-auto">
|
|
94
|
+
<Clock className="h-3 w-3" />
|
|
95
|
+
{formatRelativeDate(time)}
|
|
96
|
+
</span>
|
|
97
|
+
)}
|
|
98
|
+
</div>
|
|
99
|
+
{content && (
|
|
100
|
+
<p className="text-sm text-foreground line-clamp-4">{content}</p>
|
|
101
|
+
)}
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function LinkedInRenderer({ data }: LinkedInRendererProps) {
|
|
107
|
+
// Profile
|
|
108
|
+
if (isProfile(data)) return <ProfileCard profile={data} />;
|
|
109
|
+
|
|
110
|
+
// {posts: [...]}
|
|
111
|
+
if (typeof data === "object" && data !== null && "posts" in data) {
|
|
112
|
+
const posts = (data as any).posts;
|
|
113
|
+
if (Array.isArray(posts)) {
|
|
114
|
+
return (
|
|
115
|
+
<div className="space-y-1.5">
|
|
116
|
+
{posts.filter(isPost).map((p, i) => <PostCard key={p.id || i} post={p} />)}
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// {elements: [...]}
|
|
123
|
+
if (typeof data === "object" && data !== null && "elements" in data) {
|
|
124
|
+
const elements = (data as any).elements;
|
|
125
|
+
if (Array.isArray(elements)) {
|
|
126
|
+
const posts = elements.filter(isPost);
|
|
127
|
+
if (posts.length > 0) {
|
|
128
|
+
return (
|
|
129
|
+
<div className="space-y-1.5">
|
|
130
|
+
{posts.map((p, i) => <PostCard key={p.id || i} post={p} />)}
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (isPost(data)) return <PostCard post={data} />;
|
|
138
|
+
|
|
139
|
+
if (Array.isArray(data)) {
|
|
140
|
+
const posts = data.filter(isPost);
|
|
141
|
+
if (posts.length > 0) {
|
|
142
|
+
return <div className="space-y-1.5">{posts.map((p, i) => <PostCard key={p.id || i} post={p} />)}</div>;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<pre className="rounded-lg border border-border bg-background p-3 text-xs text-foreground overflow-x-auto max-h-96 overflow-y-auto">
|
|
148
|
+
{JSON.stringify(data, null, 2)}
|
|
149
|
+
</pre>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { FileText, Database, ExternalLink, Clock } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
interface NotionPageData {
|
|
5
|
+
id?: string;
|
|
6
|
+
url?: string;
|
|
7
|
+
last_edited_time?: string;
|
|
8
|
+
lastEditedTime?: string;
|
|
9
|
+
properties?: Record<string, unknown>;
|
|
10
|
+
title?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface NotionDatabaseData {
|
|
14
|
+
id?: string;
|
|
15
|
+
title?: string | Array<{ plain_text?: string }>;
|
|
16
|
+
description?: string | Array<{ plain_text?: string }>;
|
|
17
|
+
url?: string;
|
|
18
|
+
lastEditedTime?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface NotionRendererProps {
|
|
22
|
+
data: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isNotionPage(data: unknown): data is NotionPageData {
|
|
26
|
+
if (typeof data !== "object" || data === null) return false;
|
|
27
|
+
// Raw Notion shape with properties
|
|
28
|
+
if ("properties" in data) return true;
|
|
29
|
+
// Notion URL-based detection
|
|
30
|
+
if ("id" in data && "url" in data && String((data as any).url || "").includes("notion")) return true;
|
|
31
|
+
// Normalized Supyagent shape: has title + id + parentType (distinguishes from database)
|
|
32
|
+
if ("title" in data && "id" in data && "parentType" in data) return true;
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isNotionDatabase(data: unknown): data is NotionDatabaseData {
|
|
37
|
+
if (typeof data !== "object" || data === null) return false;
|
|
38
|
+
// Raw Notion shape: title is an array
|
|
39
|
+
if ("title" in data && Array.isArray((data as any).title) && "id" in data) return true;
|
|
40
|
+
// Normalized Supyagent shape: has propertyCount (distinguishes from pages)
|
|
41
|
+
if ("title" in data && "id" in data && "propertyCount" in data) return true;
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function extractPageTitle(page: NotionPageData): string {
|
|
46
|
+
if (page.title) return page.title;
|
|
47
|
+
if (page.properties) {
|
|
48
|
+
for (const val of Object.values(page.properties)) {
|
|
49
|
+
if (typeof val === "object" && val !== null && "title" in val) {
|
|
50
|
+
const titles = (val as any).title;
|
|
51
|
+
if (Array.isArray(titles) && titles.length > 0) {
|
|
52
|
+
return titles.map((t: any) => t.plain_text || t.text?.content || "").join("");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// Check Name property (common)
|
|
57
|
+
const name = page.properties.Name || page.properties.name;
|
|
58
|
+
if (typeof name === "object" && name !== null && "title" in name) {
|
|
59
|
+
const titles = (name as any).title;
|
|
60
|
+
if (Array.isArray(titles) && titles.length > 0) {
|
|
61
|
+
return titles.map((t: any) => t.plain_text || "").join("");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return "Untitled";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function formatRelativeDate(dateStr: string): string {
|
|
69
|
+
try {
|
|
70
|
+
const date = new Date(dateStr);
|
|
71
|
+
const now = new Date();
|
|
72
|
+
const diffMs = now.getTime() - date.getTime();
|
|
73
|
+
const diffDays = Math.floor(diffMs / 86400000);
|
|
74
|
+
if (diffDays === 0) return "today";
|
|
75
|
+
if (diffDays === 1) return "yesterday";
|
|
76
|
+
if (diffDays < 7) return `${diffDays}d ago`;
|
|
77
|
+
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
|
78
|
+
} catch {
|
|
79
|
+
return dateStr;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function PageCard({ page }: { page: NotionPageData }) {
|
|
84
|
+
const title = extractPageTitle(page);
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<div className="rounded-lg border border-border bg-card p-3 space-y-1">
|
|
88
|
+
<div className="flex items-start gap-2">
|
|
89
|
+
<FileText className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
|
|
90
|
+
<div className="min-w-0 flex-1">
|
|
91
|
+
<p className="text-sm font-medium text-foreground truncate">
|
|
92
|
+
{page.url ? (
|
|
93
|
+
<a href={page.url} target="_blank" rel="noopener noreferrer" className="hover:underline">
|
|
94
|
+
{title}
|
|
95
|
+
</a>
|
|
96
|
+
) : (
|
|
97
|
+
title
|
|
98
|
+
)}
|
|
99
|
+
</p>
|
|
100
|
+
</div>
|
|
101
|
+
<div className="flex items-center gap-1.5 shrink-0">
|
|
102
|
+
{(page.last_edited_time || page.lastEditedTime) && (
|
|
103
|
+
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
104
|
+
<Clock className="h-3 w-3" />
|
|
105
|
+
{formatRelativeDate((page.last_edited_time || page.lastEditedTime)!)}
|
|
106
|
+
</span>
|
|
107
|
+
)}
|
|
108
|
+
{page.url && (
|
|
109
|
+
<a href={page.url} target="_blank" rel="noopener noreferrer" className="text-muted-foreground hover:text-foreground">
|
|
110
|
+
<ExternalLink className="h-3 w-3" />
|
|
111
|
+
</a>
|
|
112
|
+
)}
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function DatabaseCard({ db }: { db: NotionDatabaseData }) {
|
|
120
|
+
const title = typeof db.title === "string"
|
|
121
|
+
? db.title
|
|
122
|
+
: Array.isArray(db.title) && db.title.length > 0
|
|
123
|
+
? db.title.map((t) => t.plain_text || "").join("")
|
|
124
|
+
: "Untitled database";
|
|
125
|
+
const desc = typeof db.description === "string"
|
|
126
|
+
? db.description
|
|
127
|
+
: Array.isArray(db.description) && db.description.length > 0
|
|
128
|
+
? db.description.map((d) => d.plain_text || "").join("")
|
|
129
|
+
: null;
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<div className="rounded-lg border border-border bg-card p-3 space-y-1">
|
|
133
|
+
<div className="flex items-start gap-2">
|
|
134
|
+
<Database className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
|
|
135
|
+
<div className="min-w-0 flex-1">
|
|
136
|
+
<p className="text-sm font-medium text-foreground truncate">
|
|
137
|
+
{db.url ? (
|
|
138
|
+
<a href={db.url} target="_blank" rel="noopener noreferrer" className="hover:underline">
|
|
139
|
+
{title}
|
|
140
|
+
</a>
|
|
141
|
+
) : (
|
|
142
|
+
title
|
|
143
|
+
)}
|
|
144
|
+
</p>
|
|
145
|
+
{desc && <p className="text-xs text-muted-foreground line-clamp-2">{desc}</p>}
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function NotionRenderer({ data }: NotionRendererProps) {
|
|
153
|
+
// Pages array
|
|
154
|
+
if (typeof data === "object" && data !== null && "pages" in data) {
|
|
155
|
+
const pages = (data as any).pages;
|
|
156
|
+
if (Array.isArray(pages)) {
|
|
157
|
+
return (
|
|
158
|
+
<div className="space-y-1.5">
|
|
159
|
+
{pages.filter(isNotionPage).map((p, i) => <PageCard key={p.id || i} page={p} />)}
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Databases array
|
|
166
|
+
if (typeof data === "object" && data !== null && "databases" in data) {
|
|
167
|
+
const dbs = (data as any).databases;
|
|
168
|
+
if (Array.isArray(dbs)) {
|
|
169
|
+
return (
|
|
170
|
+
<div className="space-y-1.5">
|
|
171
|
+
{dbs.filter(isNotionDatabase).map((d, i) => <DatabaseCard key={d.id || i} db={d} />)}
|
|
172
|
+
</div>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Results array (query response)
|
|
178
|
+
if (typeof data === "object" && data !== null && "results" in data) {
|
|
179
|
+
const results = (data as any).results;
|
|
180
|
+
if (Array.isArray(results)) {
|
|
181
|
+
const pages = results.filter(isNotionPage);
|
|
182
|
+
if (pages.length > 0) {
|
|
183
|
+
return (
|
|
184
|
+
<div className="space-y-1.5">
|
|
185
|
+
{pages.map((p, i) => <PageCard key={p.id || i} page={p} />)}
|
|
186
|
+
</div>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
const dbs = results.filter(isNotionDatabase);
|
|
190
|
+
if (dbs.length > 0) {
|
|
191
|
+
return (
|
|
192
|
+
<div className="space-y-1.5">
|
|
193
|
+
{dbs.map((d, i) => <DatabaseCard key={d.id || i} db={d} />)}
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Single page
|
|
201
|
+
if (isNotionPage(data)) return <PageCard page={data} />;
|
|
202
|
+
|
|
203
|
+
// Single database
|
|
204
|
+
if (isNotionDatabase(data)) return <DatabaseCard db={data} />;
|
|
205
|
+
|
|
206
|
+
// Array
|
|
207
|
+
if (Array.isArray(data)) {
|
|
208
|
+
const pages = data.filter(isNotionPage);
|
|
209
|
+
if (pages.length > 0) {
|
|
210
|
+
return (
|
|
211
|
+
<div className="space-y-1.5">
|
|
212
|
+
{pages.map((p, i) => <PageCard key={p.id || i} page={p} />)}
|
|
213
|
+
</div>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
<pre className="rounded-lg border border-border bg-background p-3 text-xs text-foreground overflow-x-auto max-h-96 overflow-y-auto">
|
|
220
|
+
{JSON.stringify(data, null, 2)}
|
|
221
|
+
</pre>
|
|
222
|
+
);
|
|
223
|
+
}
|