@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.
- package/README.md +523 -124
- package/dist/godd.cjs +293 -244
- package/dist/godd.js +1005 -321
- package/dist/index.js +148 -119
- package/notes/app/DESIGN.md +5 -2
- package/notes/app/e2e/fixtures.ts +39 -0
- package/notes/app/e2e/ja-typography-visual.spec.ts +185 -0
- package/notes/app/e2e/ja-typography-visual.spec.ts-snapshots/ja-typography-desktop-1280-chromium-linux.png +0 -0
- package/notes/app/e2e/ja-typography-visual.spec.ts-snapshots/ja-typography-mobile-360-chromium-linux.png +0 -0
- package/notes/app/e2e/rtl-logical-properties.spec.ts +36 -0
- package/notes/app/package-lock.json +0 -8
- package/notes/app/package.json +0 -1
- package/notes/app/playwright.config.ts +7 -0
- package/notes/app/pnpm-lock.yaml +0 -8
- package/notes/app/src/components/BranchSelectorModal.tsx +8 -8
- package/notes/app/src/components/CommentPanel.tsx +5 -5
- package/notes/app/src/components/DiffPreview.tsx +4 -4
- package/notes/app/src/components/FileContentView.tsx +1 -1
- package/notes/app/src/components/FileContextMenu.tsx +1 -1
- package/notes/app/src/components/FileInfoPanel.branch-tab.test.tsx +3 -35
- package/notes/app/src/components/IssueCommentThread.test.tsx +40 -19
- package/notes/app/src/components/IssueDetailView.test.tsx +3 -40
- package/notes/app/src/components/IssueList.tsx +3 -3
- package/notes/app/src/components/LabelSelector.tsx +1 -1
- package/notes/app/src/components/MarkdownEditor.tsx +1 -1
- package/notes/app/src/components/Skeleton.tsx +2 -2
- package/notes/app/src/components/ToastContainer.tsx +1 -1
- package/notes/app/src/components/TreeNode.tsx +6 -6
- package/notes/app/src/contexts/ToastContext.tsx +1 -1
- package/notes/app/src/hooks/useComments.ts +1 -1
- package/notes/app/src/hooks/useEditSession.ts +1 -1
- package/notes/app/src/lib/api.ts +1 -13
- package/notes/app/src/lib/csv-utils.ts +1 -1
- package/notes/app/src/lib/postLoginRedirect.ts +1 -1
- package/notes/app/src/lib/rtl-logical-properties.test.ts +59 -0
- package/notes/app/src/lib/user-colors.ts +1 -1
- package/notes/app/src/pages/MainPage.tsx +2 -2
- package/notes/app/src/pages/SettingsPage.test.tsx +40 -10
- package/notes/app/src/pages/SettingsPage.tsx +1 -1
- package/notes/app/src/pages/UserManagementPage.tsx +7 -7
- package/notes/app/src/styles/globals.css +17 -0
- package/notes/app/src/test/mockPeriodicSync.ts +68 -0
- package/notes/app/src/test/mocks.ts +1 -1
- package/package.json +7 -3
- package/templates/feedback/feedback-lambda/index.mjs +342 -0
- package/templates/feedback/frontend/FeedbackWidget.tsx +574 -0
- package/templates/feedback/frontend/api.ts +38 -0
- package/templates/feedback/frontend/composePinImage.ts +80 -0
- package/templates/feedback/frontend/config.ts +14 -0
- package/templates/feedback/frontend/types.ts +41 -0
- package/templates/feedback/terraform/Makefile +43 -0
- package/templates/feedback/terraform/backend.tf +10 -0
- package/templates/feedback/terraform/lambda.tf +95 -0
- package/templates/feedback/terraform/local.tf +12 -0
- package/templates/feedback/terraform/output.tf +9 -0
- package/templates/feedback/terraform/provider.tf +24 -0
- package/templates/feedback/terraform/variables.tf +25 -0
- package/templates/github-actions/aws/deploy-notes.yml.hbs +193 -10
- package/templates/notes-docs/001_project/README.md +0 -2
- package/templates/notes-docs/002_business_flow/README.md +19 -0
- package/templates/notes-docs/003_requirements/README.md +20 -0
- package/templates/notes-docs/003_requirements/questions.csv +1 -0
- package/templates/notes-docs/003_requirements/spec/README.md +11 -0
- package/templates/notes-docs/004_pages/README.md +22 -0
- package/templates/notes-docs/005_architecture/README.md +20 -0
- package/templates/notes-docs/006_development_workflow/README.md +67 -0
- package/templates/notes-docs/006_development_workflow/daily_flow.template.md +303 -0
- package/templates/notes-docs/006_development_workflow/git.template.md +72 -20
- package/templates/notes-docs/006_development_workflow/judgment.template.md +182 -0
- package/templates/notes-docs/006_development_workflow/workflow.template.md +167 -192
- package/templates/notes-docs/007_guides/README.md +85 -0
- package/templates/notes-docs/007_guides/bot-token-setup.md +174 -0
- package/templates/notes-docs/007_guides/getting_started.md +112 -0
- package/templates/notes-docs/007_guides/glossary.md +145 -0
- package/templates/notes-docs/007_guides/godd_notes_features.md +105 -0
- package/templates/notes-docs/007_guides/godd_notes_usage.md +28 -19
- package/templates/notes-docs/007_guides/godd_tools.md +129 -0
- package/templates/notes-docs/007_guides/godd_usage.md +88 -95
- package/templates/notes-docs/007_guides/infra/README.md +57 -0
- package/templates/notes-docs/007_guides/{setup_aws_accounts.md → infra/setup_aws_accounts.md} +3 -2
- package/templates/notes-docs/007_guides/{setup_infra.template.md → infra/setup_infra.template.md} +15 -4
- package/templates/notes-docs/007_guides/setup_godd.md +73 -22
- package/templates/notes-docs/007_guides/setup_godd_notes.md +97 -42
- package/templates/notes-docs/007_guides/setup_godd_notes.md.styles.json +2 -2
- package/templates/notes-docs/007_guides/setup_local_environment.template.md +81 -20
- package/templates/notes-docs/007_guides/troubleshooting.md +225 -0
- package/templates/notes-docs/999_ref/README.md +23 -0
- package/templates/notes-docs/GENERATED.md +1 -1
- package/templates/notes-docs/README.md +64 -34
- package/templates/terraform/aws/iam.tf.hbs +361 -4
- /package/templates/notes-docs/007_guides/{setup_aws_cost_alert.md → infra/setup_aws_cost_alert.md} +0 -0
- /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
|
+
}
|