@ripla/godd-mcp 1.0.7-canary.8 → 1.0.7

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.
Files changed (92) hide show
  1. package/README.md +523 -124
  2. package/dist/godd.cjs +293 -244
  3. package/dist/godd.js +1005 -321
  4. package/dist/index.js +148 -119
  5. package/notes/app/DESIGN.md +5 -2
  6. package/notes/app/e2e/fixtures.ts +39 -0
  7. package/notes/app/e2e/ja-typography-visual.spec.ts +185 -0
  8. package/notes/app/e2e/ja-typography-visual.spec.ts-snapshots/ja-typography-desktop-1280-chromium-linux.png +0 -0
  9. package/notes/app/e2e/ja-typography-visual.spec.ts-snapshots/ja-typography-mobile-360-chromium-linux.png +0 -0
  10. package/notes/app/e2e/rtl-logical-properties.spec.ts +36 -0
  11. package/notes/app/package-lock.json +0 -8
  12. package/notes/app/package.json +0 -1
  13. package/notes/app/playwright.config.ts +7 -0
  14. package/notes/app/pnpm-lock.yaml +0 -8
  15. package/notes/app/src/components/BranchSelectorModal.tsx +8 -8
  16. package/notes/app/src/components/CommentPanel.tsx +5 -5
  17. package/notes/app/src/components/DiffPreview.tsx +4 -4
  18. package/notes/app/src/components/FileContentView.tsx +1 -1
  19. package/notes/app/src/components/FileContextMenu.tsx +1 -1
  20. package/notes/app/src/components/FileInfoPanel.branch-tab.test.tsx +3 -35
  21. package/notes/app/src/components/IssueCommentThread.test.tsx +40 -19
  22. package/notes/app/src/components/IssueDetailView.test.tsx +3 -40
  23. package/notes/app/src/components/IssueList.tsx +3 -3
  24. package/notes/app/src/components/LabelSelector.tsx +1 -1
  25. package/notes/app/src/components/MarkdownEditor.tsx +1 -1
  26. package/notes/app/src/components/Skeleton.tsx +2 -2
  27. package/notes/app/src/components/ToastContainer.tsx +1 -1
  28. package/notes/app/src/components/TreeNode.tsx +6 -6
  29. package/notes/app/src/contexts/ToastContext.tsx +1 -1
  30. package/notes/app/src/hooks/useComments.ts +1 -1
  31. package/notes/app/src/hooks/useEditSession.ts +1 -1
  32. package/notes/app/src/lib/api.ts +1 -13
  33. package/notes/app/src/lib/csv-utils.ts +1 -1
  34. package/notes/app/src/lib/postLoginRedirect.ts +1 -1
  35. package/notes/app/src/lib/rtl-logical-properties.test.ts +59 -0
  36. package/notes/app/src/lib/user-colors.ts +1 -1
  37. package/notes/app/src/pages/MainPage.tsx +2 -2
  38. package/notes/app/src/pages/SettingsPage.test.tsx +40 -10
  39. package/notes/app/src/pages/SettingsPage.tsx +1 -1
  40. package/notes/app/src/pages/UserManagementPage.tsx +7 -7
  41. package/notes/app/src/styles/globals.css +17 -0
  42. package/notes/app/src/test/mockPeriodicSync.ts +68 -0
  43. package/notes/app/src/test/mocks.ts +1 -1
  44. package/package.json +7 -3
  45. package/templates/feedback/feedback-lambda/index.mjs +342 -0
  46. package/templates/feedback/frontend/FeedbackWidget.tsx +574 -0
  47. package/templates/feedback/frontend/api.ts +38 -0
  48. package/templates/feedback/frontend/composePinImage.ts +80 -0
  49. package/templates/feedback/frontend/config.ts +14 -0
  50. package/templates/feedback/frontend/types.ts +41 -0
  51. package/templates/feedback/terraform/Makefile +43 -0
  52. package/templates/feedback/terraform/backend.tf +10 -0
  53. package/templates/feedback/terraform/lambda.tf +95 -0
  54. package/templates/feedback/terraform/local.tf +12 -0
  55. package/templates/feedback/terraform/output.tf +9 -0
  56. package/templates/feedback/terraform/provider.tf +24 -0
  57. package/templates/feedback/terraform/variables.tf +25 -0
  58. package/templates/github-actions/aws/deploy-notes.yml.hbs +193 -10
  59. package/templates/notes-docs/001_project/README.md +0 -2
  60. package/templates/notes-docs/002_business_flow/README.md +19 -0
  61. package/templates/notes-docs/003_requirements/README.md +20 -0
  62. package/templates/notes-docs/003_requirements/questions.csv +1 -0
  63. package/templates/notes-docs/003_requirements/spec/README.md +11 -0
  64. package/templates/notes-docs/004_pages/README.md +22 -0
  65. package/templates/notes-docs/005_architecture/README.md +20 -0
  66. package/templates/notes-docs/006_development_workflow/README.md +67 -0
  67. package/templates/notes-docs/006_development_workflow/daily_flow.template.md +303 -0
  68. package/templates/notes-docs/006_development_workflow/git.template.md +72 -20
  69. package/templates/notes-docs/006_development_workflow/judgment.template.md +182 -0
  70. package/templates/notes-docs/006_development_workflow/workflow.template.md +167 -192
  71. package/templates/notes-docs/007_guides/README.md +85 -0
  72. package/templates/notes-docs/007_guides/bot-token-setup.md +174 -0
  73. package/templates/notes-docs/007_guides/getting_started.md +112 -0
  74. package/templates/notes-docs/007_guides/glossary.md +145 -0
  75. package/templates/notes-docs/007_guides/godd_notes_features.md +105 -0
  76. package/templates/notes-docs/007_guides/godd_notes_usage.md +28 -19
  77. package/templates/notes-docs/007_guides/godd_tools.md +129 -0
  78. package/templates/notes-docs/007_guides/godd_usage.md +88 -95
  79. package/templates/notes-docs/007_guides/infra/README.md +57 -0
  80. package/templates/notes-docs/007_guides/{setup_aws_accounts.md → infra/setup_aws_accounts.md} +3 -2
  81. package/templates/notes-docs/007_guides/{setup_infra.template.md → infra/setup_infra.template.md} +15 -4
  82. package/templates/notes-docs/007_guides/setup_godd.md +73 -22
  83. package/templates/notes-docs/007_guides/setup_godd_notes.md +97 -42
  84. package/templates/notes-docs/007_guides/setup_godd_notes.md.styles.json +2 -2
  85. package/templates/notes-docs/007_guides/setup_local_environment.template.md +81 -20
  86. package/templates/notes-docs/007_guides/troubleshooting.md +225 -0
  87. package/templates/notes-docs/999_ref/README.md +23 -0
  88. package/templates/notes-docs/GENERATED.md +1 -1
  89. package/templates/notes-docs/README.md +64 -34
  90. package/templates/terraform/aws/iam.tf.hbs +361 -4
  91. /package/templates/notes-docs/007_guides/{setup_aws_cost_alert.md → infra/setup_aws_cost_alert.md} +0 -0
  92. /package/templates/notes-docs/007_guides/{setup_aws_guard_duty.md → infra/setup_aws_guard_duty.md} +0 -0
@@ -0,0 +1,574 @@
1
+ import { type MouseEvent, useCallback, useEffect, useState } from "react";
2
+ import type { FeedbackResult } from "./api";
3
+ import { submitFeedback } from "./api";
4
+ import { composePinImage } from "./composePinImage";
5
+ import type { FeedbackPin } from "./types";
6
+
7
+ // 既存 UI に合わせた配色
8
+ const INK = "#36322b";
9
+ const SUB = "#6b6457";
10
+ const ACCENT = "#3f7a3f";
11
+ const ACCENT_INK = "#2e5c2f";
12
+ const BORDER = "#e3dccc";
13
+ const PANEL = "#fff";
14
+ const PIN_RED = "#b23b2e";
15
+ // ウィジェットは常に最前面
16
+ const Z = 2147483000;
17
+ // 起票者名の保存キー(次回以降デフォルト入力するため)
18
+ const REPORTER_STORAGE_KEY = "feedback.reporter";
19
+
20
+ function loadSavedReporter(): string {
21
+ try {
22
+ return localStorage.getItem(REPORTER_STORAGE_KEY) ?? "";
23
+ } catch {
24
+ return "";
25
+ }
26
+ }
27
+
28
+ function saveReporter(name: string) {
29
+ try {
30
+ if (name) {
31
+ localStorage.setItem(REPORTER_STORAGE_KEY, name);
32
+ } else {
33
+ localStorage.removeItem(REPORTER_STORAGE_KEY);
34
+ }
35
+ } catch {
36
+ // プライベートモード等で保存できなくても送信自体は成功させる
37
+ }
38
+ }
39
+
40
+ type Phase =
41
+ | "idle" // FAB のみ
42
+ | "capturing" // スクショ撮影中
43
+ | "placing" // ピンを置く前
44
+ | "composing" // ピン設置済み・コメント入力
45
+ | "submitting" // 送信中
46
+ | "done" // 完了
47
+ | "error"; // 失敗
48
+
49
+ export function FeedbackWidget() {
50
+ const [phase, setPhase] = useState<Phase>("idle");
51
+ const [shot, setShot] = useState<string | null>(null);
52
+ const [pin, setPin] = useState<FeedbackPin | null>(null);
53
+ const [comment, setComment] = useState("");
54
+ const [reporter, setReporter] = useState(loadSavedReporter);
55
+ const [screenName, setScreenName] = useState("");
56
+ const [error, setError] = useState<string | null>(null);
57
+ const [result, setResult] = useState<FeedbackResult | null>(null);
58
+
59
+ const reset = useCallback(() => {
60
+ setPhase("idle");
61
+ setShot(null);
62
+ setPin(null);
63
+ setComment("");
64
+ setScreenName("");
65
+ setError(null);
66
+ setResult(null);
67
+ }, []);
68
+
69
+ // Esc で中断(入力中の composing は誤操作防止のため対象外)
70
+ useEffect(() => {
71
+ if (phase === "idle" || phase === "composing" || phase === "submitting") {
72
+ return;
73
+ }
74
+ const onKey = (e: KeyboardEvent) => {
75
+ if (e.key === "Escape") reset();
76
+ };
77
+ window.addEventListener("keydown", onKey);
78
+ return () => window.removeEventListener("keydown", onKey);
79
+ }, [phase, reset]);
80
+
81
+ const startCapture = useCallback(async () => {
82
+ setError(null);
83
+
84
+ if (!navigator.mediaDevices?.getDisplayMedia) {
85
+ setError("このブラウザは画面キャプチャ(getDisplayMedia)に未対応です");
86
+ setPhase("error");
87
+ return;
88
+ }
89
+
90
+ // 現在のタブのみを共有候補にする(Chrome 系のみ有効なオプション)
91
+ const displayOptions: DisplayMediaStreamOptions = {
92
+ video: { displaySurface: "browser" } as MediaTrackConstraints,
93
+ audio: false,
94
+ };
95
+ (displayOptions as { preferCurrentTab?: boolean }).preferCurrentTab = true;
96
+
97
+ let stream: MediaStream;
98
+ try {
99
+ stream = await navigator.mediaDevices.getDisplayMedia(displayOptions);
100
+ } catch (e) {
101
+ // ユーザーが共有をキャンセルした場合は失敗扱いにせず idle に戻す
102
+ if (e instanceof DOMException && e.name === "NotAllowedError") return;
103
+ setError(e instanceof Error ? e.message : "画面キャプチャに失敗しました");
104
+ setPhase("error");
105
+ return;
106
+ }
107
+
108
+ // 実ピクセルをそのまま撮るため、自分の UI(FAB 等)を画面から消してから撮る
109
+ setPhase("capturing");
110
+ try {
111
+ // FAB が消えた描画が画面に反映されるまで少し待つ
112
+ await new Promise((r) => setTimeout(r, 250));
113
+
114
+ const video = document.createElement("video");
115
+ video.srcObject = stream;
116
+ video.muted = true;
117
+ // 解像度(videoWidth/Height)が確定するまで待つ。待たずに読むと 0 になりうる
118
+ await new Promise<void>((resolve, reject) => {
119
+ video.onloadedmetadata = () => resolve();
120
+ video.onerror = () =>
121
+ reject(new Error("キャプチャ映像の読み込みに失敗しました"));
122
+ });
123
+ await video.play();
124
+ // 最初のフレームが描画されるのを待つ
125
+ await new Promise((r) => requestAnimationFrame(() => r(null)));
126
+
127
+ const w = video.videoWidth;
128
+ const h = video.videoHeight;
129
+ const canvas = document.createElement("canvas");
130
+ canvas.width = w;
131
+ canvas.height = h;
132
+ const ctx = canvas.getContext("2d");
133
+ if (!ctx) throw new Error("Canvas コンテキストを取得できませんでした");
134
+ ctx.drawImage(video, 0, 0, w, h);
135
+ const png = canvas.toDataURL("image/png");
136
+
137
+ video.pause();
138
+ video.srcObject = null;
139
+
140
+ setShot(png);
141
+ setPhase("placing");
142
+ } catch (e) {
143
+ setError(
144
+ e instanceof Error
145
+ ? e.message
146
+ : "スクリーンショットの取得に失敗しました",
147
+ );
148
+ setPhase("error");
149
+ } finally {
150
+ for (const track of stream.getTracks()) track.stop();
151
+ }
152
+ }, []);
153
+
154
+ const onPlacePin = (e: MouseEvent<HTMLButtonElement>) => {
155
+ const rect = e.currentTarget.getBoundingClientRect();
156
+ const x = ((e.clientX - rect.left) / rect.width) * 100;
157
+ const y = ((e.clientY - rect.top) / rect.height) * 100;
158
+ setPin({ x, y });
159
+ if (phase === "placing") setPhase("composing");
160
+ };
161
+
162
+ const onSubmit = async () => {
163
+ if (!shot || !pin || !comment.trim()) return;
164
+ setPhase("submitting");
165
+ setError(null);
166
+ try {
167
+ const annotated = await composePinImage(shot, pin);
168
+ const res = await submitFeedback({
169
+ comment: comment.trim(),
170
+ reporter: reporter.trim() || undefined,
171
+ screenName: screenName.trim() || undefined,
172
+ annotatedScreenshot: annotated,
173
+ pin,
174
+ page: {
175
+ url: window.location.href,
176
+ path: window.location.pathname + window.location.hash,
177
+ title: document.title,
178
+ },
179
+ meta: {
180
+ viewport: {
181
+ width: window.innerWidth,
182
+ height: window.innerHeight,
183
+ },
184
+ userAgent: navigator.userAgent,
185
+ capturedAt: new Date().toISOString(),
186
+ },
187
+ });
188
+ saveReporter(reporter.trim());
189
+ setResult(res);
190
+ setPhase("done");
191
+ } catch (e) {
192
+ setError(e instanceof Error ? e.message : "送信に失敗しました");
193
+ setPhase("error");
194
+ }
195
+ };
196
+
197
+ const overlayActive =
198
+ phase === "placing" ||
199
+ phase === "composing" ||
200
+ phase === "submitting" ||
201
+ phase === "done" ||
202
+ phase === "error";
203
+
204
+ return (
205
+ <div data-feedback-root="true">
206
+ {/* 右下の常駐 FAB */}
207
+ {phase === "idle" && (
208
+ <button
209
+ type="button"
210
+ onClick={startCapture}
211
+ aria-label="コメントを追加"
212
+ title="コメントを追加"
213
+ style={{
214
+ position: "fixed",
215
+ right: 20,
216
+ bottom: 20,
217
+ zIndex: Z,
218
+ display: "flex",
219
+ alignItems: "center",
220
+ justifyContent: "center",
221
+ width: 56,
222
+ height: 56,
223
+ padding: 0,
224
+ borderRadius: "50%",
225
+ border: "none",
226
+ background: ACCENT,
227
+ color: "#fff",
228
+ lineHeight: 1,
229
+ cursor: "pointer",
230
+ boxShadow: "0 8px 22px rgba(54,50,43,.28)",
231
+ }}
232
+ >
233
+ {/* 絵文字は OS 依存の描画で円形が歪んで見えるため SVG で描く */}
234
+ <svg width={26} height={26} viewBox="0 0 24 24" aria-hidden="true">
235
+ <path
236
+ d="M12 3C6.9 3 3 6.4 3 10.6c0 2.4 1.3 4.5 3.3 5.9-.1 1-.6 2.1-1.5 3.1 1.7-.1 3.2-.7 4.3-1.5.9.2 1.9.4 2.9.4 5.1 0 9-3.4 9-7.9S17.1 3 12 3Z"
237
+ fill="#fff"
238
+ />
239
+ <circle cx="8.2" cy="10.6" r="1.2" fill={ACCENT} />
240
+ <circle cx="12" cy="10.6" r="1.2" fill={ACCENT} />
241
+ <circle cx="15.8" cy="10.6" r="1.2" fill={ACCENT} />
242
+ </svg>
243
+ </button>
244
+ )}
245
+
246
+ {/* 撮影中は画面に何も足さない(実ピクセルを撮る方式のため写り込み防止) */}
247
+
248
+ {/* 全画面オーバーレイ(キャプチャ前のエラーでも操作パネルは出す。
249
+ shot を必須にすると getDisplayMedia 失敗時に閉じる手段がなくなる) */}
250
+ {overlayActive && (
251
+ <div
252
+ style={{
253
+ position: "fixed",
254
+ inset: 0,
255
+ zIndex: Z,
256
+ background: "rgba(30,26,20,.78)",
257
+ display: "flex",
258
+ flexDirection: "column",
259
+ alignItems: "center",
260
+ justifyContent: "center",
261
+ padding: 24,
262
+ gap: 16,
263
+ }}
264
+ >
265
+ {/* スクショ + ピン */}
266
+ {shot && (
267
+ <div
268
+ style={{
269
+ position: "relative",
270
+ maxWidth: "90vw",
271
+ maxHeight: "72vh",
272
+ lineHeight: 0,
273
+ boxShadow: "0 10px 40px rgba(0,0,0,.4)",
274
+ borderRadius: 8,
275
+ overflow: "hidden",
276
+ }}
277
+ >
278
+ <button
279
+ type="button"
280
+ onClick={onPlacePin}
281
+ disabled={phase === "submitting"}
282
+ title="クリックして指摘位置にピンを置く"
283
+ style={{
284
+ display: "block",
285
+ padding: 0,
286
+ border: "none",
287
+ background: "none",
288
+ cursor: phase === "submitting" ? "default" : "crosshair",
289
+ }}
290
+ >
291
+ <img
292
+ src={shot}
293
+ alt="現在の画面"
294
+ style={{
295
+ display: "block",
296
+ maxWidth: "90vw",
297
+ maxHeight: "72vh",
298
+ objectFit: "contain",
299
+ }}
300
+ />
301
+ </button>
302
+ {pin && (
303
+ // 先端(下端中央)がクリック位置に一致するよう translate(-50%,-100%)
304
+ <svg
305
+ width={28}
306
+ height={37}
307
+ viewBox="0 0 24 32"
308
+ aria-hidden="true"
309
+ style={{
310
+ position: "absolute",
311
+ left: `${pin.x}%`,
312
+ top: `${pin.y}%`,
313
+ transform: "translate(-50%,-100%)",
314
+ pointerEvents: "none",
315
+ filter: "drop-shadow(0 2px 3px rgba(0,0,0,.35))",
316
+ }}
317
+ >
318
+ <title>指摘ピン</title>
319
+ <path
320
+ d="M12 1C6.201 1 1.5 5.701 1.5 11.5C1.5 19.5 12 31 12 31C12 31 22.5 19.5 22.5 11.5C22.5 5.701 17.799 1 12 1Z"
321
+ fill={PIN_RED}
322
+ stroke="#fff"
323
+ strokeWidth={2}
324
+ />
325
+ <circle cx="12" cy="11.5" r="4" fill="#fff" />
326
+ </svg>
327
+ )}
328
+ </div>
329
+ )}
330
+
331
+ {/* 操作パネル */}
332
+ <div
333
+ style={{
334
+ width: "min(560px, 90vw)",
335
+ background: PANEL,
336
+ borderRadius: 14,
337
+ border: `1px solid ${BORDER}`,
338
+ padding: 18,
339
+ boxShadow: "0 8px 30px rgba(0,0,0,.25)",
340
+ }}
341
+ >
342
+ {phase === "placing" && (
343
+ <div style={{ color: SUB, fontSize: 14, textAlign: "center" }}>
344
+ 画面の
345
+ <strong style={{ color: INK }}>気になる箇所をクリック</strong>
346
+ してピンを置いてください(Esc で取消)
347
+ </div>
348
+ )}
349
+
350
+ {(phase === "composing" || phase === "submitting") && (
351
+ <>
352
+ <div
353
+ style={{
354
+ display: "flex",
355
+ gap: 10,
356
+ marginBottom: 12,
357
+ }}
358
+ >
359
+ <div style={{ flex: 1 }}>
360
+ <label htmlFor="feedback-reporter" style={fieldLabel}>
361
+ 起票者(任意)
362
+ </label>
363
+ <input
364
+ id="feedback-reporter"
365
+ type="text"
366
+ value={reporter}
367
+ onChange={(e) => setReporter(e.target.value)}
368
+ disabled={phase === "submitting"}
369
+ placeholder="例)山田"
370
+ style={textInput}
371
+ />
372
+ </div>
373
+ <div style={{ flex: 1 }}>
374
+ <label htmlFor="feedback-screen-name" style={fieldLabel}>
375
+ 画面名(任意)
376
+ </label>
377
+ <input
378
+ id="feedback-screen-name"
379
+ type="text"
380
+ value={screenName}
381
+ onChange={(e) => setScreenName(e.target.value)}
382
+ disabled={phase === "submitting"}
383
+ placeholder="例)一覧画面"
384
+ style={textInput}
385
+ />
386
+ </div>
387
+ </div>
388
+ <label
389
+ htmlFor="feedback-comment"
390
+ style={{
391
+ display: "block",
392
+ fontSize: 13,
393
+ fontWeight: 700,
394
+ color: INK,
395
+ marginBottom: 8,
396
+ }}
397
+ >
398
+ 指摘・要望コメント
399
+ </label>
400
+ <textarea
401
+ id="feedback-comment"
402
+ value={comment}
403
+ onChange={(e) => setComment(e.target.value)}
404
+ disabled={phase === "submitting"}
405
+ placeholder="例)このボタンの色をもっと目立たせたい / 文字を大きく など"
406
+ rows={3}
407
+ style={{
408
+ width: "100%",
409
+ resize: "vertical",
410
+ padding: "10px 12px",
411
+ borderRadius: 10,
412
+ border: `1.5px solid ${BORDER}`,
413
+ fontSize: 14,
414
+ color: INK,
415
+ outline: "none",
416
+ background: "#fbf9f4",
417
+ }}
418
+ />
419
+ <div
420
+ style={{
421
+ display: "flex",
422
+ justifyContent: "space-between",
423
+ alignItems: "center",
424
+ marginTop: 12,
425
+ gap: 10,
426
+ }}
427
+ >
428
+ <span style={{ fontSize: 12, color: SUB }}>
429
+ ピンの位置は画像をクリックし直すと変更できます
430
+ </span>
431
+ <div style={{ display: "flex", gap: 8 }}>
432
+ <button
433
+ type="button"
434
+ onClick={reset}
435
+ disabled={phase === "submitting"}
436
+ style={ghostBtn}
437
+ >
438
+ キャンセル
439
+ </button>
440
+ <button
441
+ type="button"
442
+ onClick={onSubmit}
443
+ disabled={phase === "submitting" || !comment.trim()}
444
+ style={{
445
+ ...primaryBtn,
446
+ opacity:
447
+ phase === "submitting" || !comment.trim() ? 0.55 : 1,
448
+ cursor:
449
+ phase === "submitting" || !comment.trim()
450
+ ? "default"
451
+ : "pointer",
452
+ }}
453
+ >
454
+ {phase === "submitting" ? "送信中…" : "送信して起票"}
455
+ </button>
456
+ </div>
457
+ </div>
458
+ </>
459
+ )}
460
+
461
+ {phase === "done" && (
462
+ <div style={{ textAlign: "center" }}>
463
+ <div
464
+ style={{ fontSize: 15, fontWeight: 700, color: ACCENT_INK }}
465
+ >
466
+ ✓ 送信しました。Issue を作成しました。
467
+ </div>
468
+ {result?.issueUrl && (
469
+ <a
470
+ href={result.issueUrl}
471
+ target="_blank"
472
+ rel="noreferrer"
473
+ style={{
474
+ fontSize: 13,
475
+ color: ACCENT,
476
+ wordBreak: "break-all",
477
+ }}
478
+ >
479
+ {result.issueNumber
480
+ ? `Issue #${result.issueNumber} を開く`
481
+ : result.issueUrl}
482
+ </a>
483
+ )}
484
+ <div style={{ marginTop: 14 }}>
485
+ <button type="button" onClick={reset} style={primaryBtn}>
486
+ 閉じる
487
+ </button>
488
+ </div>
489
+ </div>
490
+ )}
491
+
492
+ {phase === "error" && (
493
+ <div style={{ textAlign: "center" }}>
494
+ <div
495
+ style={{
496
+ fontSize: 14,
497
+ fontWeight: 700,
498
+ color: PIN_RED,
499
+ marginBottom: 6,
500
+ }}
501
+ >
502
+ エラーが発生しました
503
+ </div>
504
+ <div style={{ fontSize: 12.5, color: SUB, marginBottom: 14 }}>
505
+ {error}
506
+ </div>
507
+ <div
508
+ style={{ display: "flex", gap: 8, justifyContent: "center" }}
509
+ >
510
+ <button type="button" onClick={reset} style={ghostBtn}>
511
+ 閉じる
512
+ </button>
513
+ {shot && (
514
+ <button
515
+ type="button"
516
+ onClick={() => {
517
+ setError(null);
518
+ setPhase(pin ? "composing" : "placing");
519
+ }}
520
+ style={primaryBtn}
521
+ >
522
+ 戻る
523
+ </button>
524
+ )}
525
+ </div>
526
+ </div>
527
+ )}
528
+ </div>
529
+ </div>
530
+ )}
531
+ </div>
532
+ );
533
+ }
534
+
535
+ const primaryBtn = {
536
+ padding: "9px 16px",
537
+ borderRadius: 10,
538
+ border: `1.5px solid ${ACCENT}`,
539
+ background: ACCENT,
540
+ color: "#fff",
541
+ fontSize: 13.5,
542
+ fontWeight: 700,
543
+ cursor: "pointer",
544
+ } as const;
545
+
546
+ const fieldLabel = {
547
+ display: "block",
548
+ fontSize: 13,
549
+ fontWeight: 700,
550
+ color: INK,
551
+ marginBottom: 8,
552
+ } as const;
553
+
554
+ const textInput = {
555
+ width: "100%",
556
+ padding: "8px 12px",
557
+ borderRadius: 10,
558
+ border: `1.5px solid ${BORDER}`,
559
+ fontSize: 14,
560
+ color: INK,
561
+ outline: "none",
562
+ background: "#fbf9f4",
563
+ } as const;
564
+
565
+ const ghostBtn = {
566
+ padding: "9px 16px",
567
+ borderRadius: 10,
568
+ border: `1.5px solid ${BORDER}`,
569
+ background: "#fff",
570
+ color: INK,
571
+ fontSize: 13.5,
572
+ fontWeight: 700,
573
+ cursor: "pointer",
574
+ } as const;
@@ -0,0 +1,38 @@
1
+ import { FEEDBACK_API_URL, FEEDBACK_TOKEN } from "./config";
2
+ import type { FeedbackPayload } from "./types";
3
+
4
+ /** バックエンドが返す起票結果 */
5
+ export interface FeedbackResult {
6
+ /** 作成された GitHub Issue の URL */
7
+ issueUrl?: string;
8
+ /** Issue 番号 */
9
+ issueNumber?: number;
10
+ }
11
+
12
+ /**
13
+ * フィードバックをバックエンドへ送信する。
14
+ * バックエンド側でスクショの S3 保存・GitHub Issue 作成・Slack 通知を行う想定。
15
+ */
16
+ export async function submitFeedback(
17
+ payload: FeedbackPayload,
18
+ ): Promise<FeedbackResult> {
19
+ const headers: Record<string, string> = {
20
+ "Content-Type": "application/json",
21
+ };
22
+ if (FEEDBACK_TOKEN) headers["X-Feedback-Token"] = FEEDBACK_TOKEN;
23
+
24
+ const res = await fetch(FEEDBACK_API_URL, {
25
+ method: "POST",
26
+ headers,
27
+ body: JSON.stringify(payload),
28
+ });
29
+
30
+ if (!res.ok) {
31
+ const detail = await res.text().catch(() => "");
32
+ throw new Error(
33
+ `送信に失敗しました (HTTP ${res.status})${detail ? `: ${detail}` : ""}`,
34
+ );
35
+ }
36
+
37
+ return (await res.json().catch(() => ({}))) as FeedbackResult;
38
+ }
@@ -0,0 +1,80 @@
1
+ import type { FeedbackPin } from "./types";
2
+
3
+ const PIN_RED = "#b23b2e";
4
+
5
+ /**
6
+ * スクリーンショット画像にピンを合成した PNG data URL を返す。
7
+ * ピン座標は画像に対する相対 %(0〜100)。
8
+ */
9
+ export async function composePinImage(
10
+ screenshotDataUrl: string,
11
+ pin: FeedbackPin,
12
+ ): Promise<string> {
13
+ const img = await loadImage(screenshotDataUrl);
14
+ const canvas = document.createElement("canvas");
15
+ canvas.width = img.width;
16
+ canvas.height = img.height;
17
+ const ctx = canvas.getContext("2d");
18
+ if (!ctx) throw new Error("Canvas コンテキストを取得できませんでした");
19
+
20
+ ctx.drawImage(img, 0, 0);
21
+
22
+ const px = (pin.x / 100) * img.width;
23
+ const py = (pin.y / 100) * img.height;
24
+ drawPin(ctx, px, py, Math.max(28, img.width * 0.025));
25
+
26
+ return canvas.toDataURL("image/png");
27
+ }
28
+
29
+ function loadImage(src: string): Promise<HTMLImageElement> {
30
+ return new Promise((resolve, reject) => {
31
+ const img = new Image();
32
+ img.onload = () => resolve(img);
33
+ img.onerror = () => reject(new Error("画像の読み込みに失敗しました"));
34
+ img.src = src;
35
+ });
36
+ }
37
+
38
+ /**
39
+ * ピン先端(下端中央)が (cx, cy) に来るドロップピンを描画する。
40
+ * size はピンの横幅(px)。
41
+ */
42
+ function drawPin(
43
+ ctx: CanvasRenderingContext2D,
44
+ cx: number,
45
+ cy: number,
46
+ size: number,
47
+ ) {
48
+ const s = size / 24;
49
+ ctx.save();
50
+ ctx.translate(cx - 12 * s, cy - 32 * s);
51
+ ctx.scale(s, s);
52
+
53
+ // ドロップ形状
54
+ ctx.beginPath();
55
+ ctx.moveTo(12, 31);
56
+ ctx.bezierCurveTo(12, 31, 1.5, 19.5, 1.5, 11.5);
57
+ ctx.bezierCurveTo(1.5, 5.701, 6.201, 1, 12, 1);
58
+ ctx.bezierCurveTo(17.799, 1, 22.5, 5.701, 22.5, 11.5);
59
+ ctx.bezierCurveTo(22.5, 19.5, 12, 31, 12, 31);
60
+ ctx.closePath();
61
+
62
+ ctx.shadowColor = "rgba(0,0,0,0.35)";
63
+ ctx.shadowBlur = 4 * s;
64
+ ctx.shadowOffsetY = 2 * s;
65
+ ctx.fillStyle = PIN_RED;
66
+ ctx.fill();
67
+ ctx.shadowColor = "transparent";
68
+
69
+ ctx.strokeStyle = "#fff";
70
+ ctx.lineWidth = 2;
71
+ ctx.stroke();
72
+
73
+ // 中央の白丸
74
+ ctx.beginPath();
75
+ ctx.arc(12, 11.5, 4, 0, Math.PI * 2);
76
+ ctx.fillStyle = "#fff";
77
+ ctx.fill();
78
+
79
+ ctx.restore();
80
+ }