Skip to content

Commit 92730d7

Browse files
committed
訪問予定リストの順番をその場で入れ替えられるようにする
リスト詳細と地図の経路詳細に並び替えハンドルを足した。回る順番を直すのに 地図の作成モードまで行かずに済むようにする。 - 並び替えの操作を lib/useDragReorder.ts に切り出し、作成モードのパネルと 共用する(ハンドルにだけ touch-action: none を当てる規則も含めて1か所に) - ドラッグ中は手元の並びだけを動かし、指を離した時点で1回だけ PATCH する。 失敗したら理由を出してサーバーの状態へ戻す - 経路詳細で動かせるのは経路に出ている地点だけ。訪問済み・手元に無い スポットは spot_ids の中で位置を保つ - 経路詳細の行のキーを位置からスポットIDに変える(位置だと並べ替えのたびに 行が作り直され、ポインタの捕捉が外れて追従しなくなる) あわせて「探訪スポットを追加」の入口を廃止し、スポットの追加フォームの 折り畳み(名前とよみがなの間・同じ体裁)に畳んだ。メニューの段階で 「訪問も記録するか」を決めさせていて、選び直すには開き直す必要があった。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent a57a3e0 commit 92730d7

8 files changed

Lines changed: 365 additions & 160 deletions

File tree

CLAUDE.md

Lines changed: 13 additions & 3 deletions
Large diffs are not rendered by default.

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ docker compose pull && docker compose up -d
184184

185185
| パス | 内容 |
186186
|---|---|
187-
| `/[type]/map` | 地図(ホーム)。シリーズ・訪問状態・カテゴリでフィルタ。ピンタップ→スポット詳細モーダルへ。別の種別のダウンロード済み公開スポットを半透明で重ねて表示することもできる(複数の種別を同時に重ねられる。タップで読み取り専用の詳細を表示し、そこから訪問記録と今の種別の訪問予定リストへの追加ができる)。経路・訪問順・訪問予定リストの詳細からはGoogle マップの経路検索で開ける。左下に今表示中のスポット種別名を小さく表示 |
187+
| `/[type]/map` | 地図(ホーム)。シリーズ・訪問状態・カテゴリでフィルタ。ピンタップ→スポット詳細モーダルへ。別の種別のダウンロード済み公開スポットを半透明で重ねて表示することもできる(複数の種別を同時に重ねられる。タップで読み取り専用の詳細を表示し、そこから訪問記録と今の種別の訪問予定リストへの追加ができる)。経路・訪問順・訪問予定リストの詳細からはGoogle マップの経路検索で開ける(訪問予定リストの詳細では、地点の左端の≡をつかんで回る順番を入れ替えられる)。左下に今表示中のスポット種別名を小さく表示 |
188188
| `/[type]/spots` | 「都道府県から探す」(地域別ドリルダウン)と「シリーズから探す」(検索+絞り込み+ページング)の2タブ |
189189
| `/[type]/admin` | (管理者・スポット管理者専用)スポットの承認待ちキュー・追加・編集・削除・CSVインポート・経路(巡った順の矢印)のインポート・travel-log-dataへの還元用エクスポート(手動追加した公開スポットと画面から削除したCSV由来スポットの一覧をMarkdownでダウンロード)。adminのみキー一覧を指定しての削除・GitHubリポジトリ(travel-log-data形式)からの一括取り込み・スポット種別の管理・ユーザー管理も可能 |
190190
| `/[type]/account` | 自分のロール表示、ログアウト、他のスポット種別への切り替え |
@@ -204,6 +204,8 @@ docker compose pull && docker compose up -d
204204
`/[type]/map`上で右クリック(モバイルは長押し)するとスポットを追加できる。送信時のstatusは
205205
ロールにより既定が異なり(`user`は非公開、それ以外は承認待ち)、admin/spot_adminは
206206
`/[type]/admin`の承認待ちキューから個別承認、または「すべて承認」で一括公開できる。
207+
追加フォームの「訪問を記録」(名前欄の下)を開いたまま送信すると、そのスポットに訪問記録が1件つく
208+
(いま訪れている場所を、追加と記録の2手順を踏まずに残せる)。
207209

208210
## 訪問予定・未訪問記録・口コミ・訪問写真
209211

components/AddSpotModal.tsx

Lines changed: 51 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ export default function AddSpotModal({
2929
spot,
3030
spots,
3131
role,
32-
withVisit = false,
3332
onClose,
3433
onSaved,
3534
onDeleted,
@@ -46,11 +45,10 @@ export default function AddSpotModal({
4645
spots: Spot[];
4746
/** 選べるstatusの選択肢を決める(新規作成時のみ使用。nullなら非公開のみ扱う) */
4847
role: Role | null;
49-
/** trueにすると「探訪スポットを追加」モード:名前とよみがなの間に訪問記録
50-
* (訪問日時・写真・メモ)の入力欄を出し、スポット作成と同時に訪問を1件記録する */
51-
withVisit?: boolean;
5248
onClose: () => void;
53-
onSaved: (spot: Spot) => void;
49+
/** `visitRecorded`は「訪問を記録」を開いたまま送信し、訪問記録も1件付けたとき。
50+
* 呼び出し側が訪問済みの表示・訪問順の経路を取り直すのに使う */
51+
onSaved: (spot: Spot, visitRecorded: boolean) => void;
5452
onDeleted?: () => void;
5553
}) {
5654
const isEdit = !!spot;
@@ -82,7 +80,10 @@ export default function AddSpotModal({
8280
const [error, setError] = useState<string | null>(null);
8381
const [locating, setLocating] = useState(false);
8482

85-
// 探訪スポット追加(withVisit)のときだけ使う、同時に付ける訪問記録の入力値
83+
// 追加と同時に付ける訪問記録。既定は畳んであり、**開いたまま送信したときだけ**
84+
// 記録する(開く操作自体が「ここへ行った」の意思表示。畳んだまま送れば
85+
// 入力欄に既定値が入っていても記録は付かない)
86+
const [recordVisit, setRecordVisit] = useState(false);
8687
const [visitedOn, setVisitedOn] = useState(() =>
8788
toDateTimeLocalValue(new Date())
8889
);
@@ -185,8 +186,9 @@ export default function AddSpotModal({
185186
setError("送信に失敗しました: " + (error?.message ?? "unknown error"));
186187
return;
187188
}
188-
// 探訪スポット追加のときは、作成したスポットに訪問記録を1件つける(口コミは無し)
189-
if (withVisit && !isEdit) {
189+
// 「訪問を記録」を開いていたときは、作成したスポットに訪問記録を1件つける(口コミは無し)
190+
const withVisit = recordVisit && !isEdit;
191+
if (withVisit) {
190192
const { error: visitError } = await api.visits.create({
191193
spot_id: data.id,
192194
visited_on: visitedOn ? new Date(visitedOn).toISOString() : null,
@@ -203,7 +205,7 @@ export default function AddSpotModal({
203205
}
204206
}
205207
setSaving(false);
206-
onSaved(data);
208+
onSaved(data, withVisit);
207209
};
208210

209211
const handleDelete = async () => {
@@ -231,11 +233,7 @@ export default function AddSpotModal({
231233
className="max-h-[85dvh] w-full max-w-md space-y-3 overflow-y-auto rounded-2xl bg-white p-4"
232234
>
233235
<h2 className="font-bold">
234-
{isEdit
235-
? "スポットを編集"
236-
: withVisit
237-
? "この場所に探訪スポットを追加"
238-
: "この場所にスポットを追加"}
236+
{isEdit ? "スポットを編集" : "この場所にスポットを追加"}
239237
</h2>
240238
{!isEdit && lat != null && lng != null && (
241239
<p className="text-xs text-gray-500">
@@ -289,19 +287,45 @@ export default function AddSpotModal({
289287
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
290288
/>
291289
</div>
292-
{/* 探訪スポット追加: 名前とよみがなの間に訪問記録の入力欄を出す */}
293-
{withVisit && !isEdit && (
294-
<div className="space-y-3 rounded-lg border border-blue-100 bg-blue-50/40 p-3">
295-
<p className="text-sm font-medium text-blue-800">訪問を記録</p>
296-
<VisitFields
297-
visitedOn={visitedOn}
298-
onVisitedOnChange={setVisitedOn}
299-
memo={visitMemo}
300-
onMemoChange={setVisitMemo}
301-
photos={visitPhotos}
302-
onPhotosChange={setVisitPhotos}
303-
onProcessingChange={setProcessingPhotos}
304-
/>
290+
{/* 追加と同時に訪問を記録する欄。「探訪スポットを追加」だった頃と同じく
291+
名前とよみがなの間・同じ体裁(青の見出しと薄青の面)で置き、
292+
**既定は畳んでおく**(ふだんの追加では使わないため)。
293+
かつては長押しメニューの別の入口だったが、同じフォームの折り畳みにして
294+
入口を1つにした */}
295+
{!isEdit && (
296+
<div className="rounded-lg border border-blue-100 bg-blue-50/40">
297+
<button
298+
type="button"
299+
onClick={() => setRecordVisit((prev) => !prev)}
300+
aria-expanded={recordVisit}
301+
className="flex w-full items-center justify-between gap-2 p-3 text-left text-sm font-medium text-blue-800"
302+
>
303+
<span>
304+
訪問を記録
305+
<span className="ml-1 text-xs font-normal text-blue-700/70">
306+
(任意)
307+
</span>
308+
</span>
309+
<span className="text-xs text-blue-700/70">
310+
{recordVisit ? "▲" : "▼"}
311+
</span>
312+
</button>
313+
{recordVisit && (
314+
<div className="space-y-3 border-t border-blue-100 p-3">
315+
<p className="text-xs text-gray-500">
316+
開いたまま送信すると、追加したスポットに訪問記録が1件つきます(口コミは無し)。畳むと記録しません。
317+
</p>
318+
<VisitFields
319+
visitedOn={visitedOn}
320+
onVisitedOnChange={setVisitedOn}
321+
memo={visitMemo}
322+
onMemoChange={setVisitMemo}
323+
photos={visitPhotos}
324+
onPhotosChange={setVisitPhotos}
325+
onProcessingChange={setProcessingPhotos}
326+
/>
327+
</div>
328+
)}
305329
</div>
306330
)}
307331
<div>

components/MapView.tsx

Lines changed: 96 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import {
5959
useSpotCache,
6060
type DownloadProgress,
6161
} from "@/lib/useSpotCache";
62+
import { useDragReorder, REORDER_HANDLE_CLASS } from "@/lib/useDragReorder";
6263
import { useSeriesStyles } from "@/lib/useSeriesStyles";
6364
import { useCategories } from "@/lib/useCategories";
6465
import { useCategoryStyles } from "@/lib/useCategoryStyles";
@@ -1953,11 +1954,6 @@ export default function MapView({
19531954
const [addSpotAt, setAddSpotAt] = useState<{ lat: number; lng: number } | null>(
19541955
null
19551956
);
1956-
// 「探訪スポットを追加」(スポット追加と同時に訪問記録をつける)の対象座標
1957-
const [visitSpotAt, setVisitSpotAt] = useState<{
1958-
lat: number;
1959-
lng: number;
1960-
} | null>(null);
19611957
const [pendingSpots, setPendingSpots] = useState<
19621958
{ id: string; lat: number; lng: number; name: string; status: string }[]
19631959
>([]);
@@ -2905,8 +2901,11 @@ export default function MapView({
29052901
description: list.description,
29062902
pointNoun: "地点",
29072903
editList: list,
2908-
points: path.map((s, i) => ({
2909-
key: `${s.id}-${i}`,
2904+
// 並び替えでドラッグ中も行の要素を作り直さないよう、キーは位置ではなく
2905+
// スポットのID(リスト内で一意)にする —— 作り直すとポインタの捕捉が
2906+
// 外れて、指を離すまで追従しなくなる
2907+
points: path.map((s) => ({
2908+
key: s.id,
29102909
spotId: s.id,
29112910
name: s.name,
29122911
lng: s.lng,
@@ -2916,7 +2915,66 @@ export default function MapView({
29162915
};
29172916
})()
29182917
: null;
2918+
// 訪問予定リストの経路詳細だけ、地点をつかんで回る順番を入れ替えられる
2919+
// (ルートと訪問順は記録・取り込み済みの事実なので並べ替えない)。
2920+
// ドラッグ中は手元のリストを差し替えて地図の紫の矢印もその場で追従させ、
2921+
// 指を離した時点で1回だけPATCHする
2922+
const detailPanelRef = useRef<HTMLDivElement | null>(null);
2923+
const [orderError, setOrderError] = useState<string | null>(null);
2924+
const [savingOrder, setSavingOrder] = useState(false);
2925+
const reorderList = routeDetailView?.editList ?? null;
2926+
/** 経路に出ている地点の新しい並びを、リスト全体(訪問済み・手元に無いスポットを
2927+
* 含む`spot_ids`)へ書き戻す。経路に出ていない行は元の位置のまま動かさない */
2928+
const applyPathOrder = (list: VisitPlanList, orderedIds: string[]) => {
2929+
const shown = new Set(orderedIds);
2930+
let i = 0;
2931+
return list.spot_ids.map((id) => (shown.has(id) ? orderedIds[i++] : id));
2932+
};
2933+
const {
2934+
setRowRef: setPointRowRef,
2935+
dragIndex: pointDragIndex,
2936+
handleProps: pointHandleProps,
2937+
} = useDragReorder({
2938+
items: reorderList ? routeDetailView!.points : [],
2939+
onReorder: (points) => {
2940+
if (!reorderList) return;
2941+
const spotIds = applyPathOrder(
2942+
reorderList,
2943+
points.map((p) => p.spotId)
2944+
);
2945+
setPlanLists((prev) =>
2946+
prev.map((l) => (l.id === reorderList.id ? { ...l, spot_ids: spotIds } : l))
2947+
);
2948+
},
2949+
onCommit: async (points) => {
2950+
if (!reorderList) return;
2951+
const spotIds = applyPathOrder(
2952+
reorderList,
2953+
points.map((p) => p.spotId)
2954+
);
2955+
setSavingOrder(true);
2956+
setOrderError(null);
2957+
// PATCHは経由スポットを丸ごと置き換えるので基本情報も送り直す
2958+
// (送らないと題名・期間が消える。訪問済みはAPI側が控えて戻す)
2959+
const { error } = await api.visitPlanLists.update(reorderList.id, {
2960+
title: reorderList.title,
2961+
description: reorderList.description,
2962+
start_date: reorderList.start_date,
2963+
end_date: reorderList.end_date,
2964+
spot_ids: spotIds,
2965+
});
2966+
setSavingOrder(false);
2967+
if (error) {
2968+
setOrderError("並び順の保存に失敗しました: " + error.message);
2969+
}
2970+
// 成否によらずサーバーの状態に合わせ直す(失敗時は保存できていない並びを残さない)
2971+
loadPlanLists();
2972+
},
2973+
scrollRef: detailPanelRef,
2974+
});
2975+
29192976
const closeRouteDetail = () => {
2977+
setOrderError(null);
29202978
setDetailRouteId(null);
29212979
setOverlayDetailRouteId(null);
29222980
setDetailPathKind(null);
@@ -3637,15 +3695,6 @@ export default function MapView({
36373695
>
36383696
ここにスポットを追加
36393697
</button>
3640-
<button
3641-
onClick={() => {
3642-
setVisitSpotAt({ lat: contextMenu.lat, lng: contextMenu.lng });
3643-
setContextMenu(null);
3644-
}}
3645-
className="block w-full whitespace-nowrap px-4 py-2 text-left text-sm hover:bg-gray-50"
3646-
>
3647-
探訪スポットを追加
3648-
</button>
36493698
</div>
36503699
</>
36513700
)}
@@ -3659,7 +3708,7 @@ export default function MapView({
36593708
spots={spots}
36603709
role={role}
36613710
onClose={() => setAddSpotAt(null)}
3662-
onSaved={(spot) => {
3711+
onSaved={(spot, visitRecorded) => {
36633712
if (spot.status === "private") {
36643713
// 非公開は自分にだけ常に見えるので、通常のスポットと同じように取り直して表示する
36653714
loadPrivateSpots();
@@ -3675,43 +3724,13 @@ export default function MapView({
36753724
},
36763725
]);
36773726
}
3727+
// 追加と同時に訪問を記録したときは、訪問済み表示・訪問日の経路も更新する
3728+
if (visitRecorded) loadVisits();
36783729
setAddSpotAt(null);
36793730
}}
36803731
/>
36813732
)}
36823733

3683-
{/* 探訪スポット追加モーダル(スポット追加と同時に訪問記録をつける) */}
3684-
{visitSpotAt && (
3685-
<AddSpotModal
3686-
lat={visitSpotAt.lat}
3687-
lng={visitSpotAt.lng}
3688-
spotTypeKey={spotTypeKey}
3689-
spots={spots}
3690-
role={role}
3691-
withVisit
3692-
onClose={() => setVisitSpotAt(null)}
3693-
onSaved={(spot) => {
3694-
if (spot.status === "private") {
3695-
loadPrivateSpots();
3696-
} else {
3697-
setPendingSpots((prev) => [
3698-
...prev,
3699-
{
3700-
id: spot.id,
3701-
lat: spot.lat,
3702-
lng: spot.lng,
3703-
name: spot.name,
3704-
status: spot.status,
3705-
},
3706-
]);
3707-
}
3708-
// 訪問記録も同時についたので、訪問済み表示・訪問日の経路を更新する
3709-
loadVisits();
3710-
setVisitSpotAt(null);
3711-
}}
3712-
/>
3713-
)}
3714-
37153734
{/* ルート・経路の詳細モーダル(ルート/訪問順の経路/訪問予定リストの経路の線・矢印の
37163735
タップで開く。重ね表示のルートも共用) */}
37173736
{routeDetailView && (
@@ -3720,6 +3739,7 @@ export default function MapView({
37203739
onClick={closeRouteDetail}
37213740
>
37223741
<div
3742+
ref={detailPanelRef}
37233743
onClick={(e) => e.stopPropagation()}
37243744
className="max-h-[85dvh] w-full max-w-md space-y-3 overflow-y-auto rounded-2xl bg-white p-4"
37253745
>
@@ -3769,8 +3789,23 @@ export default function MapView({
37693789
{/* 全地点を巡った順に並べ、2点の間にその区間の説明(ルートのみ)を挟む */}
37703790
<ol className="space-y-0.5">
37713791
{routeDetailView.points.map((point, i) => (
3772-
<li key={point.key}>
3773-
<div className="flex items-center gap-2">
3792+
<li key={point.key} ref={setPointRowRef(i)}>
3793+
<div
3794+
className={`flex items-center gap-2 ${
3795+
pointDragIndex === i ? "bg-blue-100" : ""
3796+
}`}
3797+
>
3798+
{/* 訪問予定リストのときだけ、つかんで回る順番を入れ替えられる。
3799+
touch-action: noneはハンドルにだけ当てる(行本体まで
3800+
当てると一覧がタッチスクロールできなくなる) */}
3801+
{reorderList && (
3802+
<span
3803+
{...pointHandleProps(i)}
3804+
className={`${REORDER_HANDLE_CLASS} self-stretch py-1 pl-0.5 pr-0.5 text-base leading-none`}
3805+
>
3806+
<span className="flex h-full items-center"></span>
3807+
</span>
3808+
)}
37743809
<span className="w-6 shrink-0 text-right text-xs font-medium tabular-nums text-gray-500">
37753810
{i + 1}
37763811
</span>
@@ -3812,6 +3847,8 @@ export default function MapView({
38123847
{/* 区間の説明は次の地点との間に表示(最終地点には次の区間が無い) */}
38133848
{i < routeDetailView.points.length - 1 && (
38143849
<div className="flex items-baseline gap-2 py-0.5 text-xs text-gray-500">
3850+
{/* 並び替えハンドルのぶんの空き(番号の列を上下でそろえる) */}
3851+
{reorderList && <span className="w-5 shrink-0" />}
38153852
<span className="w-6 shrink-0 text-right"></span>
38163853
{point.legDescription && (
38173854
<span className="min-w-0 whitespace-pre-wrap">
@@ -3826,7 +3863,15 @@ export default function MapView({
38263863
<p className="pt-2 text-xs text-gray-500">
38273864
{routeDetailView.pointNoun}
38283865
{routeDetailView.points.length}件。スポット名をタップすると、その位置へ移動して詳細を開きます。
3866+
{reorderList &&
3867+
routeDetailView.points.length > 1 &&
3868+
(savingOrder
3869+
? "並び順を保存中…"
3870+
: "左端の≡をつかんで動かすと、回る順番を入れ替えられます(訪問済みのスポットは経路に出ないため動きません)。")}
38293871
</p>
3872+
{orderError && (
3873+
<p className="pt-1 text-xs text-red-600">{orderError}</p>
3874+
)}
38303875
{/* 経路全体をGoogle マップの経路検索で開く(先頭が出発地、
38313876
途中が経由地、最後が目的地) */}
38323877
<div className="pt-2">

0 commit comments

Comments
 (0)