Skip to content

Commit d1283ce

Browse files
authored
Merge pull request #5 from opencom-org/pr/widget-improvements
Pr/widget improvements
2 parents 7066c26 + b8a8401 commit d1283ce

62 files changed

Lines changed: 5129 additions & 627 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/web/e2e/help-center-import.spec.ts

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import * as fs from "node:fs";
2+
import * as os from "node:os";
13
import * as path from "node:path";
24
import { test, expect } from "./fixtures";
35
import {
@@ -23,17 +25,45 @@ test.describe("Help Center Markdown Import", () => {
2325
timeout: 10000,
2426
});
2527

26-
const docsFolderPath = path.resolve(process.cwd(), "docs");
27-
await page.getByTestId("markdown-import-folder-input").setInputFiles(docsFolderPath);
28-
await expect(page.getByTestId("markdown-import-selection-count")).toBeVisible({
29-
timeout: 10000,
30-
});
28+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "opencom-help-import-"));
29+
const docsFolderPath = path.join(tempRoot, "docs");
30+
const imagesFolderPath = path.join(docsFolderPath, "images");
31+
fs.mkdirSync(imagesFolderPath, { recursive: true });
32+
fs.writeFileSync(
33+
path.join(docsFolderPath, "architecture-overview.md"),
34+
[
35+
"This article includes a local image.",
36+
"",
37+
"![Architecture](images/diagram.png)",
38+
"",
39+
"high-level architecture",
40+
].join("\n"),
41+
"utf8"
42+
);
43+
fs.writeFileSync(
44+
path.join(imagesFolderPath, "diagram.png"),
45+
Buffer.from(
46+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP4DwQACfsD/aCFJdEAAAAASUVORK5CYII=",
47+
"base64"
48+
)
49+
);
3150

32-
await page.getByTestId("markdown-import-preview-button").click();
33-
await expect(page.getByText(/Preview ready\./i)).toBeVisible({ timeout: 45000 });
51+
try {
52+
await page.getByTestId("markdown-import-folder-input").setInputFiles(docsFolderPath);
53+
await expect(page.getByTestId("markdown-import-selection-count")).toBeVisible({
54+
timeout: 10000,
55+
});
3456

35-
await page.getByTestId("markdown-import-sync-button").click();
36-
await expect(page.getByText(/Synced \d+ markdown files\./i)).toBeVisible({ timeout: 45000 });
57+
await page.getByTestId("markdown-import-preview-button").click();
58+
await expect(page.getByText(/Preview ready\./i)).toBeVisible({ timeout: 45000 });
59+
60+
await page.getByTestId("markdown-import-sync-button").click();
61+
await expect(
62+
page.getByText(/Synced \d+ markdown file\(s\) and \d+ image file\(s\)\./i)
63+
).toBeVisible({ timeout: 45000 });
64+
} finally {
65+
fs.rmSync(tempRoot, { recursive: true, force: true });
66+
}
3767

3868
const downloadPromise = page.waitForEvent("download");
3969
await page.getByTestId("markdown-export-button").click();
@@ -54,5 +84,6 @@ test.describe("Help Center Markdown Import", () => {
5484
timeout: 10000,
5585
});
5686
await expect(page.getByText(/high-level architecture/i)).toBeVisible({ timeout: 10000 });
87+
await expect(page.locator("article img").first()).toBeVisible({ timeout: 10000 });
5788
});
5889
});

apps/web/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,15 @@
2121
"dompurify": "^3.3.1",
2222
"fflate": "^0.8.2",
2323
"lucide-react": "^0.469.0",
24+
"markdown-it": "^14.1.1",
2425
"next": "^15.5.10",
2526
"react": "^19.2.3",
2627
"react-dom": "^19.2.3"
2728
},
2829
"devDependencies": {
2930
"@testing-library/jest-dom": "^6.6.3",
3031
"@testing-library/react": "^16.3.0",
32+
"@types/markdown-it": "^14.1.2",
3133
"@types/node": "^20.10.0",
3234
"@types/react": "^19.2.9",
3335
"@types/react-dom": "^19.2.3",

apps/web/src/app/articles/[id]/page.tsx

Lines changed: 158 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useState, useEffect } from "react";
3+
import { useState, useEffect, useRef } from "react";
44
import { useParams } from "next/navigation";
55
import { useQuery, useMutation } from "convex/react";
66
import { api } from "@opencom/convex";
@@ -27,8 +27,16 @@ export default function ArticleEditorPage() {
2727
const [audienceRules, setAudienceRules] = useState<InlineAudienceRule | null>(null);
2828
const [isSaving, setIsSaving] = useState(false);
2929
const [hasChanges, setHasChanges] = useState(false);
30+
const [isUploadingImage, setIsUploadingImage] = useState(false);
31+
const [assetError, setAssetError] = useState<string | null>(null);
32+
const [removingAssetId, setRemovingAssetId] = useState<Id<"articleAssets"> | null>(null);
33+
const uploadInputRef = useRef<HTMLInputElement | null>(null);
3034

3135
const article = useQuery(api.articles.get, { id: articleId });
36+
const articleAssets = useQuery(
37+
api.articles.listAssets,
38+
activeWorkspace?._id ? { workspaceId: activeWorkspace._id, articleId } : "skip"
39+
);
3240
const collections = useQuery(
3341
api.collections.listHierarchy,
3442
activeWorkspace?._id ? { workspaceId: activeWorkspace._id } : "skip"
@@ -37,6 +45,9 @@ export default function ArticleEditorPage() {
3745
const updateArticle = useMutation(api.articles.update);
3846
const publishArticle = useMutation(api.articles.publish);
3947
const unpublishArticle = useMutation(api.articles.unpublish);
48+
const generateAssetUploadUrl = useMutation(api.articles.generateAssetUploadUrl);
49+
const saveAsset = useMutation(api.articles.saveAsset);
50+
const deleteAsset = useMutation(api.articles.deleteAsset);
4051

4152
useEffect(() => {
4253
if (article) {
@@ -98,6 +109,80 @@ export default function ArticleEditorPage() {
98109
setHasChanges(true);
99110
};
100111

112+
const appendAssetReference = (reference: string, altText: string) => {
113+
const safeAlt = altText.replace(/\.(png|jpe?g|gif|webp|avif)$/i, "").replace(/[-_]+/g, " ");
114+
const snippet = `\n\n![${safeAlt}](${reference})\n`;
115+
setContent((current) => `${current}${snippet}`);
116+
setHasChanges(true);
117+
};
118+
119+
const handleImageUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
120+
const file = event.target.files?.[0];
121+
if (!file || !activeWorkspace?._id) {
122+
return;
123+
}
124+
125+
setAssetError(null);
126+
setIsUploadingImage(true);
127+
try {
128+
const uploadUrl = await generateAssetUploadUrl({ workspaceId: activeWorkspace._id });
129+
const uploadResponse = await fetch(uploadUrl, {
130+
method: "POST",
131+
headers: {
132+
"Content-Type": file.type || "application/octet-stream",
133+
},
134+
body: file,
135+
});
136+
if (!uploadResponse.ok) {
137+
throw new Error("Image upload failed");
138+
}
139+
140+
const uploadPayload = (await uploadResponse.json()) as { storageId?: Id<"_storage"> };
141+
if (!uploadPayload.storageId) {
142+
throw new Error("Upload response missing storage id");
143+
}
144+
145+
const savedAsset = await saveAsset({
146+
workspaceId: activeWorkspace._id,
147+
articleId,
148+
storageId: uploadPayload.storageId,
149+
fileName: file.name,
150+
});
151+
appendAssetReference(savedAsset.reference, savedAsset.fileName ?? file.name);
152+
} catch (error) {
153+
console.error("Failed to upload article image:", error);
154+
setAssetError(error instanceof Error ? error.message : "Failed to upload image.");
155+
} finally {
156+
setIsUploadingImage(false);
157+
if (uploadInputRef.current) {
158+
uploadInputRef.current.value = "";
159+
}
160+
}
161+
};
162+
163+
const handleDeleteAsset = async (assetId: Id<"articleAssets">) => {
164+
if (!activeWorkspace?._id) {
165+
return;
166+
}
167+
setAssetError(null);
168+
setRemovingAssetId(assetId);
169+
try {
170+
await deleteAsset({
171+
workspaceId: activeWorkspace._id,
172+
assetId,
173+
});
174+
} catch (error) {
175+
console.error("Failed to delete article asset:", error);
176+
setAssetError(
177+
error instanceof Error
178+
? error.message
179+
: "Failed to delete image. Remove markdown references first."
180+
);
181+
} finally {
182+
setRemovingAssetId(null);
183+
}
184+
};
185+
101186
if (!article) {
102187
return (
103188
<div className="flex items-center justify-center h-screen">
@@ -171,7 +256,7 @@ export default function ArticleEditorPage() {
171256
onChange={(e) => handleCollectionChange(e.target.value)}
172257
className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
173258
>
174-
<option value="">Uncategorized</option>
259+
<option value="">General</option>
175260
{collections?.map((collection: NonNullable<typeof collections>[number]) => (
176261
<option key={collection._id} value={collection._id}>
177262
{collection.name}
@@ -190,6 +275,77 @@ export default function ArticleEditorPage() {
190275
/>
191276
<p className="text-xs text-gray-500 mt-2">Supports Markdown formatting</p>
192277
</div>
278+
279+
<div className="rounded-md border border-gray-200 bg-gray-50 p-4 space-y-3">
280+
<div className="flex items-center justify-between gap-3 flex-wrap">
281+
<div>
282+
<p className="text-sm font-medium text-gray-900">Images</p>
283+
<p className="text-xs text-gray-600">
284+
Upload an image to insert markdown like{" "}
285+
<code className="font-mono">![alt](oc-asset://...)</code>
286+
</p>
287+
</div>
288+
<input
289+
ref={uploadInputRef}
290+
type="file"
291+
accept=".png,.jpg,.jpeg,.gif,.webp,.avif,image/png,image/jpeg,image/gif,image/webp,image/avif"
292+
className="hidden"
293+
onChange={handleImageUpload}
294+
/>
295+
<Button
296+
variant="outline"
297+
size="sm"
298+
onClick={() => uploadInputRef.current?.click()}
299+
disabled={isUploadingImage}
300+
>
301+
{isUploadingImage ? "Uploading..." : "Upload Image"}
302+
</Button>
303+
</div>
304+
305+
{assetError && (
306+
<div className="text-xs text-red-700 rounded border border-red-200 bg-red-50 px-2 py-1">
307+
{assetError}
308+
</div>
309+
)}
310+
311+
{(articleAssets?.length ?? 0) > 0 && (
312+
<div className="space-y-2">
313+
{articleAssets?.map((asset: NonNullable<typeof articleAssets>[number]) => (
314+
<div
315+
key={asset._id}
316+
className="flex items-center justify-between gap-2 rounded border border-gray-200 bg-white px-3 py-2"
317+
>
318+
<div className="min-w-0">
319+
<div className="text-sm font-medium text-gray-900 truncate">
320+
{asset.fileName}
321+
</div>
322+
<div className="text-xs text-gray-500 font-mono truncate">
323+
{asset.reference}
324+
</div>
325+
</div>
326+
<div className="flex items-center gap-2">
327+
<Button
328+
variant="outline"
329+
size="sm"
330+
onClick={() => appendAssetReference(asset.reference, asset.fileName)}
331+
>
332+
Insert
333+
</Button>
334+
<Button
335+
variant="outline"
336+
size="sm"
337+
onClick={() => handleDeleteAsset(asset._id)}
338+
disabled={removingAssetId === asset._id}
339+
className="text-red-700 border-red-200 hover:text-red-800"
340+
>
341+
{removingAssetId === asset._id ? "Deleting..." : "Delete"}
342+
</Button>
343+
</div>
344+
</div>
345+
))}
346+
</div>
347+
)}
348+
</div>
193349
</div>
194350

195351
<div className="bg-white rounded-lg border p-6 mt-6">

0 commit comments

Comments
 (0)