Skip to content

Commit 42f87d7

Browse files
hubyrodclaude
andcommitted
Add publish form to post summary to a Slashwork group
After generating a summary, users can pick a target group and an auth token identity to publish the summary as a post via the Slashwork GraphQL API. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent aacb2e7 commit 42f87d7

4 files changed

Lines changed: 215 additions & 4 deletions

File tree

app/api/summary/publish/route.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { auth } from "@clerk/nextjs/server";
2+
import { NextResponse } from "next/server";
3+
import { getGroups, getAuthToken } from "@/src/db";
4+
import { postToSlashwork } from "@/src/slashwork";
5+
6+
export async function POST(request: Request) {
7+
const { userId } = await auth();
8+
if (!userId) {
9+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
10+
}
11+
12+
let body: { targetGroup?: string; authTokenName?: string; markdown?: string };
13+
try {
14+
body = await request.json();
15+
} catch {
16+
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
17+
}
18+
19+
const { targetGroup, authTokenName, markdown } = body;
20+
if (!targetGroup || !authTokenName || !markdown) {
21+
return NextResponse.json(
22+
{ error: "targetGroup, authTokenName, and markdown are required" },
23+
{ status: 400 },
24+
);
25+
}
26+
27+
const graphqlUrl = process.env.SLASHWORK_GRAPHQL_URL;
28+
if (!graphqlUrl) {
29+
return NextResponse.json({ error: "SLASHWORK_GRAPHQL_URL not configured" }, { status: 500 });
30+
}
31+
32+
// Resolve group
33+
const groups = await getGroups();
34+
const group = groups.find((g) => g.name === targetGroup);
35+
if (!group) {
36+
return NextResponse.json({ error: `Group '${targetGroup}' not found` }, { status: 404 });
37+
}
38+
39+
// Resolve auth token
40+
const token = await getAuthToken(authTokenName);
41+
if (!token) {
42+
return NextResponse.json({ error: `Auth token '${authTokenName}' not found` }, { status: 404 });
43+
}
44+
45+
try {
46+
await postToSlashwork(
47+
{ graphqlUrl, authToken: token },
48+
group.slashworkId,
49+
markdown,
50+
);
51+
return NextResponse.json({ ok: true });
52+
} catch (err) {
53+
const message = err instanceof Error ? err.message : String(err);
54+
return NextResponse.json({ error: `Publish failed: ${message}` }, { status: 502 });
55+
}
56+
}

app/summary/page.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { getGroups } from "@/src/db";
1+
import { getGroups, getAuthTokens } from "@/src/db";
22
import { hasDatabase } from "@/src/lib/config";
33
import SummaryForm from "./summary-form";
44
import "./summary.css";
@@ -7,7 +7,9 @@ export const dynamic = "force-dynamic";
77

88
export default async function SummaryPage() {
99
const dbAvailable = hasDatabase();
10-
const groups = dbAvailable ? await getGroups() : [];
10+
const [groups, authTokens] = dbAvailable
11+
? await Promise.all([getGroups(), getAuthTokens()])
12+
: [[], []];
1113

1214
return (
1315
<div className="sum-page">
@@ -25,7 +27,10 @@ export default async function SummaryPage() {
2527
</div>
2628
)}
2729

28-
<SummaryForm groups={groups.map((g) => g.name)} />
30+
<SummaryForm
31+
groups={groups.map((g) => g.name)}
32+
authTokenNames={authTokens.map((t) => t.name)}
33+
/>
2934
</main>
3035
</div>
3136
);

app/summary/summary-form.tsx

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useState } from "react";
44

55
interface SummaryFormProps {
66
groups: string[];
7+
authTokenNames: string[];
78
}
89

910
function getISOWeek(date: Date): number {
@@ -28,7 +29,7 @@ const WEEK_OPTIONS = Array.from({ length: 52 }, (_, i) => {
2829
const DEFAULT_PROMPT =
2930
"Summarize the following messages. Give a concise overview of the key topics, decisions, and action items discussed:";
3031

31-
export default function SummaryForm({ groups }: SummaryFormProps) {
32+
export default function SummaryForm({ groups, authTokenNames }: SummaryFormProps) {
3233
const [group, setGroup] = useState(groups[0] ?? "");
3334
const [week, setWeek] = useState(`week${String(currentWeek).padStart(2, "0")}`);
3435
const [prompt, setPrompt] = useState(DEFAULT_PROMPT);
@@ -38,6 +39,13 @@ export default function SummaryForm({ groups }: SummaryFormProps) {
3839
const [weekLabel, setWeekLabel] = useState("");
3940
const [error, setError] = useState("");
4041

42+
// Publish state
43+
const [publishGroup, setPublishGroup] = useState(groups[0] ?? "");
44+
const [publishAs, setPublishAs] = useState(authTokenNames[0] ?? "");
45+
const [publishing, setPublishing] = useState(false);
46+
const [publishStatus, setPublishStatus] = useState<"idle" | "success" | "error">("idle");
47+
const [publishError, setPublishError] = useState("");
48+
4149
async function handleSubmit(e: React.FormEvent) {
4250
e.preventDefault();
4351
setLoading(true);
@@ -75,6 +83,44 @@ export default function SummaryForm({ groups }: SummaryFormProps) {
7583
}
7684
}
7785

86+
async function handlePublish() {
87+
setPublishing(true);
88+
setPublishStatus("idle");
89+
setPublishError("");
90+
91+
try {
92+
const res = await fetch("/api/summary/publish", {
93+
method: "POST",
94+
headers: { "Content-Type": "application/json" },
95+
body: JSON.stringify({
96+
targetGroup: publishGroup,
97+
authTokenName: publishAs,
98+
markdown: summary,
99+
}),
100+
});
101+
102+
const text = await res.text();
103+
let data: Record<string, unknown>;
104+
try {
105+
data = JSON.parse(text);
106+
} catch {
107+
throw new Error(`Server error (HTTP ${res.status})`);
108+
}
109+
110+
if (!res.ok) {
111+
throw new Error((data.error as string) || `HTTP ${res.status}`);
112+
}
113+
114+
setPublishStatus("success");
115+
setTimeout(() => setPublishStatus("idle"), 5000);
116+
} catch (err) {
117+
setPublishStatus("error");
118+
setPublishError(err instanceof Error ? err.message : String(err));
119+
} finally {
120+
setPublishing(false);
121+
}
122+
}
123+
78124
return (
79125
<div className="sum-section">
80126
<form className="sum-form" onSubmit={handleSubmit}>
@@ -159,6 +205,56 @@ export default function SummaryForm({ groups }: SummaryFormProps) {
159205
</span>
160206
</div>
161207
<div className="sum-result-body">{summary}</div>
208+
209+
<div className="sum-publish">
210+
<div className="sum-publish-header">Publish</div>
211+
<div className="sum-form-row">
212+
<div className="sum-field">
213+
<label htmlFor="sum-publish-group">Post to</label>
214+
<select
215+
id="sum-publish-group"
216+
value={publishGroup}
217+
onChange={(e) => setPublishGroup(e.target.value)}
218+
>
219+
{groups.map((g) => (
220+
<option key={g} value={g}>{g}</option>
221+
))}
222+
</select>
223+
</div>
224+
<div className="sum-field">
225+
<label htmlFor="sum-publish-as">Post as</label>
226+
<select
227+
id="sum-publish-as"
228+
value={publishAs}
229+
onChange={(e) => setPublishAs(e.target.value)}
230+
>
231+
{authTokenNames.map((t) => (
232+
<option key={t} value={t}>{t}</option>
233+
))}
234+
</select>
235+
</div>
236+
<div className="sum-field sum-field--action">
237+
<button
238+
type="button"
239+
className="sum-publish-btn"
240+
disabled={publishing}
241+
onClick={handlePublish}
242+
>
243+
{publishing ? "Publishing..." : "Publish"}
244+
</button>
245+
</div>
246+
</div>
247+
{publishStatus === "success" && (
248+
<div className="sum-publish-status sum-publish-status--ok">
249+
Published to {publishGroup}
250+
</div>
251+
)}
252+
{publishStatus === "error" && (
253+
<div className="sum-publish-status sum-publish-status--err">
254+
{publishError}
255+
</div>
256+
)}
257+
</div>
162258
</div>
163259
)}
164260
</div>

app/summary/summary.css

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,60 @@
233233
white-space: pre-wrap;
234234
}
235235

236+
/* Publish */
237+
.sum-publish {
238+
margin-top: 1.25rem;
239+
border-top: 1px solid var(--cfg-border);
240+
padding-top: 1.25rem;
241+
}
242+
243+
.sum-publish-header {
244+
font-family: var(--cfg-mono);
245+
font-size: 0.6875rem;
246+
font-weight: 500;
247+
text-transform: uppercase;
248+
letter-spacing: 0.08em;
249+
color: var(--cfg-text-muted);
250+
margin-bottom: 0.75rem;
251+
}
252+
253+
.sum-publish-btn {
254+
font-family: var(--cfg-mono);
255+
font-size: 0.75rem;
256+
font-weight: 600;
257+
color: var(--cfg-bg);
258+
background: var(--cfg-green);
259+
border: none;
260+
border-radius: 4px;
261+
padding: 0.5rem 1.25rem;
262+
cursor: pointer;
263+
transition: opacity 0.15s ease;
264+
white-space: nowrap;
265+
}
266+
267+
.sum-publish-btn:hover {
268+
opacity: 0.9;
269+
}
270+
271+
.sum-publish-btn:disabled {
272+
opacity: 0.5;
273+
cursor: not-allowed;
274+
}
275+
276+
.sum-publish-status {
277+
margin-top: 0.625rem;
278+
font-family: var(--cfg-mono);
279+
font-size: 0.6875rem;
280+
}
281+
282+
.sum-publish-status--ok {
283+
color: var(--cfg-green);
284+
}
285+
286+
.sum-publish-status--err {
287+
color: #ef4444;
288+
}
289+
236290
@media (max-width: 768px) {
237291
.sum-form-row {
238292
flex-direction: column;

0 commit comments

Comments
 (0)