Skip to content

Commit e3d3765

Browse files
mattakamatsuclaude
andcommitted
Merge origin/main into canvas-frame-embed
Resolves the CanvasEmbed.tsx conflict with the "Edit Block" chrome that landed on main (#—, commit 69f7d22). Both sides rewrote renderCanvasEmbed's tail; the combined version keeps main's handleEditBlock/CanvasEmbedChrome for the whole-canvas path and this branch's frame routing for the frame path: frame ? <CanvasFrameEmbed> : <CanvasEmbedChrome title location> Title/frame parsing stays on parseDgCanvasEmbed (supersedes the removed BLOCK_TEXT_REGEX/extractCanvasTitle); both `frame` and `location` are computed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 parents ce449ee + 69f7d22 commit e3d3765

78 files changed

Lines changed: 1539 additions & 2480 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.

CONTRIBUTING.md

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ If you’re uncertain about the value of your proposed change, don’t hesitate
1212

1313
Here’s how to contribute:
1414

15-
1. [Fork](https://docs.github.com/en/github/getting-started-with-github/fork-a-repo) and [clone](https://docs.github.com/en/github/creating-cloning-and-archiving-repositories/cloning-a-repository) the repository.
15+
1. [Fork](https://docs.github.com/en/github/getting-started-with-github/fork-a-repo) or [clone](https://docs.github.com/en/github/creating-cloning-and-archiving-repositories/cloning-a-repository) the repository.
1616
2. [Create a new branch](https://docs.github.com/en/desktop/contributing-and-collaborating-using-github-desktop/managing-branches) for your updates.
1717
3. Implement your changes, making sure your code:
1818
- Is formatted with [Prettier](https://prettier.io)
@@ -50,11 +50,25 @@ Blog posts are located in `/apps/website/content/blog/`
5050

5151
### Plugin Documentation
5252

53-
Detailed guidance for plugin docs lives next to the update-user-docs skill:
53+
Plugin docs live in the Nextra content tree:
5454

55-
- **[navigation-mapping.md](./skills/update-user-docs/references/navigation-mapping.md)** — where files live, `docMap.ts` (shared pages), and `navigation.ts` (every new page)
56-
- **[doc-conventions.md](./skills/update-user-docs/references/doc-conventions.md)** — filenames, frontmatter, screenshots, and cross-links
57-
- **[scope-detection.md](./skills/update-user-docs/references/scope-detection.md)** — how changed file paths map to Obsidian vs Roam vs shared docs (useful when unsure where a doc belongs)
55+
- **Obsidian docs:** `apps/website/content/obsidian/...`
56+
- **Roam docs:** `apps/website/content/roam/...`
57+
58+
Sidebar order comes from the nearest `_meta.ts` file in the content tree. If you add a page, add the Markdown or MDX file in the right section and update that section's `_meta.ts`.
59+
60+
Flat legacy redirects, such as `/docs/obsidian/<slug>` to `/docs/obsidian/<section>/<slug>`, are maintained in `apps/website/docsRouteMap.ts`.
61+
62+
Use existing Nextra Markdown, MDX, and `nextra/components` features for styling and layout before proposing anything custom. For example, use Nextra callouts, cards, steps, tabs, tables, and file trees when those fit the content.
63+
64+
If the docs need a styling or presentation feature that Nextra does not currently provide, create a separate Linear ticket to add that Nextra functionality. Do not include theme, layout, route, component, or CSS changes in a content-only docs update.
65+
66+
Preferred: Use the `$update-user-docs` skill to update plugin docs. Detailed guidance for plugin docs lives next to the `$update-user-docs` skill:
67+
68+
- **[llm-authoring-guide.md](./skills/update-user-docs/references/llm-authoring-guide.md)** - a short guide non-devs can give to an LLM before asking it to write or update docs
69+
- **[navigation-mapping.md](./skills/update-user-docs/references/navigation-mapping.md)** - how `_meta.ts` controls sidebar registration and when `docsRouteMap.ts` needs a redirect
70+
- **[doc-conventions.md](./skills/update-user-docs/references/doc-conventions.md)** - filenames, frontmatter, screenshots, and cross-links
71+
- **[scope-detection.md](./skills/update-user-docs/references/scope-detection.md)** - how changed file paths map to Obsidian, Roam, both-platform, or docs-site updates
5872

5973
### Documentation Images
6074

@@ -74,8 +88,8 @@ When referencing images in your documentation, use relative paths from the publi
7488
To preview your changes locally:
7589

7690
1. **Environment setup**: Copy `/apps/website/.env.example` to `/apps/website/.env` and configure any necessary environment variables
77-
2. **Install dependencies**: Run `npm install` from the project root
78-
3. **Start development server**: Run `npm run dev` or `npx turbo dev` to start the website locally
91+
2. **Install dependencies**: Run `pnpm install` from the project root
92+
3. **Start development server**: Run `pnpm exec turbo dev -F website` or navigate to `apps/website` and run `pnpm dev` to start the website locally
7993
4. **View your changes**: Navigate to `http://localhost:3000` to see your documentation
8094

8195
The website uses Next.js with the App Router, so changes to Markdown files should be reflected automatically during development.
Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
1+
import { nextApiRoot } from "@repo/utils/execContext";
2+
import { App, Modal, Notice, requestUrl, setIcon } from "obsidian";
3+
import { createRoot, Root } from "react-dom/client";
4+
import { StrictMode, useState, useRef, useEffect } from "react";
5+
import type DiscourseGraphPlugin from "~/index";
6+
7+
const FEEDBACK_ENDPOINT = `${nextApiRoot()}/feedback`;
8+
9+
const FEEDBACK_TYPES = ["feedback", "bugReport", "featureRequest"] as const;
10+
11+
type FeedbackType = (typeof FEEDBACK_TYPES)[number];
12+
13+
const FEEDBACK_TYPE_LABELS: Record<FeedbackType, string> = {
14+
feedback: "Feedback",
15+
bugReport: "Bug report",
16+
featureRequest: "Feature request",
17+
};
18+
19+
const isFeedbackType = (value: string): value is FeedbackType =>
20+
(FEEDBACK_TYPES as readonly string[]).includes(value);
21+
22+
const readFileAsBase64 = (file: File): Promise<string> =>
23+
new Promise((resolve, reject) => {
24+
const reader = new FileReader();
25+
reader.onload = () => {
26+
const base64 = (reader.result as string).split(",")[1];
27+
if (!base64) {
28+
reject(new Error("Failed to read file as base64"));
29+
return;
30+
}
31+
resolve(base64);
32+
};
33+
reader.onerror = reject;
34+
reader.readAsDataURL(file);
35+
});
36+
37+
const fieldClass =
38+
"w-full bg-modifier-form-field border border-modifier-border rounded text-normal px-3 py-2 text-sm box-border";
39+
40+
const selectClass = `${fieldClass} appearance-none h-9 leading-none`;
41+
42+
const accentButtonClass =
43+
"flex items-center justify-center gap-1.5 bg-accent text-on-accent rounded py-2.5 px-3 text-sm font-medium border-none cursor-pointer hover:opacity-90";
44+
45+
const isValidEmail = (value: string) =>
46+
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
47+
48+
type FeedbackContentProps = {
49+
plugin: DiscourseGraphPlugin;
50+
onClose: () => void;
51+
};
52+
53+
const FeedbackContent = ({ plugin, onClose }: FeedbackContentProps) => {
54+
const [email, setEmail] = useState("");
55+
const [title, setTitle] = useState("");
56+
const [description, setDescription] = useState("");
57+
const [feedbackType, setFeedbackType] = useState<FeedbackType>("bugReport");
58+
const [screenshot, setScreenshot] = useState<File | null>(null);
59+
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
60+
const [emailError, setEmailError] = useState<string | null>(null);
61+
const [isSubmitting, setIsSubmitting] = useState(false);
62+
const fileInputRef = useRef<HTMLInputElement>(null);
63+
64+
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
65+
const value = e.target.value;
66+
setEmail(value);
67+
if (emailError && (!value.trim() || isValidEmail(value.trim()))) {
68+
setEmailError(null);
69+
}
70+
};
71+
72+
const handleEmailBlur = () => {
73+
const trimmed = email.trim();
74+
if (trimmed && !isValidEmail(trimmed)) {
75+
setEmailError("Please enter a valid email address.");
76+
} else {
77+
setEmailError(null);
78+
}
79+
};
80+
81+
useEffect(() => {
82+
if (!screenshot) {
83+
setPreviewUrl(null);
84+
return;
85+
}
86+
const url = URL.createObjectURL(screenshot);
87+
setPreviewUrl(url);
88+
return () => URL.revokeObjectURL(url);
89+
}, [screenshot]);
90+
91+
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
92+
setScreenshot(e.target.files?.[0] ?? null);
93+
};
94+
95+
const removeScreenshot = () => {
96+
setScreenshot(null);
97+
if (fileInputRef.current) fileInputRef.current.value = "";
98+
};
99+
100+
const isSubmittable =
101+
title.trim().length > 0 &&
102+
email.trim().length > 0 &&
103+
isValidEmail(email.trim()) &&
104+
!emailError;
105+
106+
const handleSubmit = async () => {
107+
if (!isSubmittable) return;
108+
109+
setIsSubmitting(true);
110+
try {
111+
const payload: {
112+
email?: string;
113+
title: string;
114+
description?: string;
115+
type: FeedbackType;
116+
screenshot?: { data: string; mimeType: string; name: string };
117+
pluginVersion: string;
118+
} = {
119+
email: email.trim() || undefined,
120+
title: title.trim(),
121+
description: description.trim() || undefined,
122+
type: feedbackType,
123+
pluginVersion: plugin.manifest.version,
124+
};
125+
126+
if (screenshot) {
127+
payload.screenshot = {
128+
data: await readFileAsBase64(screenshot),
129+
mimeType: screenshot.type,
130+
name: screenshot.name,
131+
};
132+
}
133+
134+
const response = await requestUrl({
135+
url: FEEDBACK_ENDPOINT,
136+
method: "POST",
137+
headers: { "Content-Type": "application/json" },
138+
body: JSON.stringify(payload),
139+
});
140+
141+
if (response.status >= 200 && response.status < 300) {
142+
new Notice("Feedback submitted — thank you!");
143+
onClose();
144+
} else {
145+
new Notice("Failed to submit feedback. Please try again.");
146+
}
147+
} catch (err) {
148+
console.error("[FeedbackModal] request failed:", err);
149+
new Notice("Failed to submit feedback. Please check your connection.");
150+
} finally {
151+
setIsSubmitting(false);
152+
}
153+
};
154+
155+
return (
156+
<div className="flex flex-col gap-3 pb-2">
157+
{/* Screenshot button */}
158+
<input
159+
ref={fileInputRef}
160+
type="file"
161+
accept="image/*"
162+
className="hidden"
163+
onChange={handleFileChange}
164+
/>
165+
<button
166+
onClick={() => fileInputRef.current?.click()}
167+
className={`${accentButtonClass} w-full`}
168+
>
169+
<span
170+
ref={(el) => {
171+
if (el) setIcon(el, "image");
172+
}}
173+
className="inline-flex items-center"
174+
aria-hidden
175+
/>
176+
Take screenshot
177+
</button>
178+
179+
{/* Screenshot preview */}
180+
{screenshot && previewUrl && (
181+
<div className="bg-modifier-form-field border-modifier-border flex items-center gap-2 rounded border p-2">
182+
<img
183+
src={previewUrl}
184+
alt="Screenshot preview"
185+
className="h-12 w-12 flex-shrink-0 rounded object-cover"
186+
/>
187+
<span className="text-muted flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-xs">
188+
{screenshot.name}
189+
</span>
190+
<button
191+
onClick={removeScreenshot}
192+
aria-label="Remove screenshot"
193+
className="text-muted flex-shrink-0 cursor-pointer border-none bg-transparent p-0.5 hover:opacity-70"
194+
>
195+
<span
196+
ref={(el) => {
197+
if (el) setIcon(el, "x");
198+
}}
199+
className="inline-flex items-center"
200+
aria-hidden
201+
/>
202+
</button>
203+
</div>
204+
)}
205+
206+
{/* Email */}
207+
<div className="flex flex-col gap-1">
208+
<label className="text-normal text-xs font-medium">
209+
Email <span className="text-error">*</span>
210+
</label>
211+
<input
212+
type="email"
213+
value={email}
214+
onChange={handleEmailChange}
215+
onBlur={handleEmailBlur}
216+
placeholder="your@email.com"
217+
className={`${fieldClass} ${emailError ? "border-error" : ""}`}
218+
/>
219+
{emailError && <span className="text-error text-xs">{emailError}</span>}
220+
</div>
221+
222+
{/* Title */}
223+
<div className="flex flex-col gap-1">
224+
<label className="text-normal text-xs font-medium">
225+
Title <span className="text-error">*</span>
226+
</label>
227+
<input
228+
type="text"
229+
value={title}
230+
onChange={(e) => setTitle(e.target.value)}
231+
placeholder="Add a title"
232+
className={fieldClass}
233+
/>
234+
</div>
235+
236+
{/* Description */}
237+
<div className="flex flex-col gap-1">
238+
<label className="text-normal text-xs font-medium">
239+
Description <span className="text-muted font-normal">(optional)</span>
240+
</label>
241+
<textarea
242+
value={description}
243+
onChange={(e) => setDescription(e.target.value)}
244+
placeholder="Add a description"
245+
rows={4}
246+
className={`${fieldClass} resize-y`}
247+
/>
248+
</div>
249+
250+
{/* Feedback type */}
251+
<select
252+
value={feedbackType}
253+
onChange={(e) => {
254+
if (isFeedbackType(e.target.value)) {
255+
setFeedbackType(e.target.value);
256+
}
257+
}}
258+
className={selectClass}
259+
>
260+
{FEEDBACK_TYPES.map((type) => (
261+
<option key={type} value={type}>
262+
{FEEDBACK_TYPE_LABELS[type]}
263+
</option>
264+
))}
265+
</select>
266+
267+
{/* Submit */}
268+
<div className="flex justify-end">
269+
<button
270+
onClick={() => void handleSubmit()}
271+
disabled={isSubmitting || !isSubmittable}
272+
className={`${accentButtonClass} px-5 disabled:cursor-not-allowed disabled:opacity-40`}
273+
>
274+
{isSubmitting ? (
275+
"Submitting…"
276+
) : (
277+
<>
278+
Submit
279+
<span
280+
ref={(el) => {
281+
if (el) setIcon(el, "send");
282+
}}
283+
className="inline-flex items-center"
284+
aria-hidden
285+
/>
286+
</>
287+
)}
288+
</button>
289+
</div>
290+
</div>
291+
);
292+
};
293+
294+
export class FeedbackModal extends Modal {
295+
private plugin: DiscourseGraphPlugin;
296+
private root: Root | null = null;
297+
298+
constructor(app: App, plugin: DiscourseGraphPlugin) {
299+
super(app);
300+
this.plugin = plugin;
301+
}
302+
303+
onOpen(): void {
304+
this.titleEl.setText("Discourse Graphs feedback");
305+
const { contentEl } = this;
306+
contentEl.empty();
307+
this.root = createRoot(contentEl);
308+
this.root.render(
309+
<StrictMode>
310+
<FeedbackContent plugin={this.plugin} onClose={() => this.close()} />
311+
</StrictMode>,
312+
);
313+
}
314+
315+
onClose(): void {
316+
if (this.root) {
317+
this.root.unmount();
318+
this.root = null;
319+
}
320+
}
321+
}

0 commit comments

Comments
 (0)