From a9b6be1a4c1899e39323d38a9d59a31237abe17c Mon Sep 17 00:00:00 2001 From: kamijo-haruto Date: Sat, 25 Jul 2026 17:30:07 +0900 Subject: [PATCH 01/10] =?UTF-8?q?[feat]=20=E3=83=88=E3=83=BC=E3=82=B9?= =?UTF-8?q?=E3=83=88=E9=80=9A=E7=9F=A5=E3=81=A8=E5=85=B1=E9=80=9A=E3=83=A2?= =?UTF-8?q?=E3=83=BC=E3=83=80=E3=83=AB=E3=83=BB=E5=89=8A=E9=99=A4=E7=A2=BA?= =?UTF-8?q?=E8=AA=8D=E3=83=80=E3=82=A4=E3=82=A2=E3=83=AD=E3=82=B0=E3=83=BB?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E3=81=AAlocalStorage=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4画面に個別パッチを当てる代わりに、後続の修正が乗る共通基盤を先に作る。 ## lib/storage.ts — safeStorage Safari のプライベートブラウズやストレージが無効な環境では localStorage への アクセス(getter含む)自体が例外を投げる。それが致命的になる箇所が2つあった: トークンの読み書きと、detectDefaultLocale が useState の初期化子内で 呼ばれている箇所——後者は throw するとアプリ全体がマウント時に白画面になる。 現場ではスマホを共用しプライベートタブで開くこともあるので、管理画面を丸ごと 失う現実的な経路だった。失敗時はメモリにフォールバックし、タブの寿命の間は セッションと言語切替が動く(永続化されないだけ)。 ## components/ToastProvider.tsx 単なる4つのインラインバナーのDRY化ではない。**メッセージを出した コンポーネントがアンマウントしても生き残る唯一の構造**で、これがアプリ最悪の バグの根本原因への対処になる: QRコード編集の保存中にキャンセルするとフォームが 閉じ、失敗が「もう描画されていないフォームの formError」に書かれていた。 保存が失敗したのに利用者は何も知らされない状態だった。 加えて、これまで皆無だった成功フィードバックを供給する。作成・編集・削除は すべて無言で完了しており、毎回「今の操作は成功したのか?」を疑う必要があった。 表示位置も意図的で、モバイルでは下端中央にした。従来のバナーはページ最上部に 出るため、スマホで9行目を削除して失敗すると画面外にスクロールしていて 「何も起きなかった」と見分けがつかなかった。errorは role=alert、それ以外は role=status で読み上げる(従来はどのエラーも読み上げられなかった)。 ## components/Modal.tsx 置き換え対象の QRDialog は role="dialog" aria-modal="true" を持ちながら、 フォーカストラップ・オートフォーカス・Escape・フォーカス復帰・スクロール ロックがすべて無く、Tabがオーバーレイ背後のページに抜けていた。さらにパネルに onKeyDown={(e) => e.stopPropagation()} が付いていて**キーイベントの伝播を 能動的に止めていた**ため、上位にEscapeハンドラを足しても発火し得なかった。 キーハンドラはキャプチャ段で登録し、子がpropagationを止めてもEscapeが死なない ようにしている。 ## components/ConfirmDialog.tsx window.confirm の3つの実害への対処: (1) どの項目を削除するのか言わない (プロジェクト削除はQRコードとアクセスログにカスケードし、スマホでは編集と削除の ボタンが約8px・高さ28pxで隣接している)、(2) Chromeが連続ダイアログを抑制した 後は confirm() が黙ってfalseを返し削除が無反応になる、(3) 進行状態を出せない ので実行中に無効化できず、ダブルタップでDELETEが2回飛び2回目の404が 「削除成功後のエラー」として表示されていた。 ## lib/styles.ts コンポーネント化ではなくクラス名定数にした。4画面のアプリでは全コントロールを ラップする労力に見合わない。必要だったのは**タップ領域を1箇所で直せること**で、 ページネーションのシェブロンが主要ナビゲーションなのに28pxだった(推奨44px未満)。 入力欄の text-base も意図的で、16px未満だとiOS Safariがフォーカス時に ビューポートをズームし、以降フォームの操作が狂ったズームのまま続く。 ## その他 - ProtectedRoute のハードコード "Loading…" を i18n 化(common.loading は 既に存在した)。日本語利用者が毎回最初に見る文字列。 - エラー・検証・セッション切れ用の i18n キーを ja/en 両方に追加。アプリが 到達しうるのに言葉が無かった状態を埋める。 Co-Authored-By: Claude Opus 5 (1M context) --- packages/web/src/App.tsx | 87 +++++----- packages/web/src/components/ConfirmDialog.tsx | 84 ++++++++++ .../web/src/components/FullScreenMessage.tsx | 48 ++++++ packages/web/src/components/Modal.tsx | 154 ++++++++++++++++++ .../web/src/components/ProtectedRoute.tsx | 15 +- packages/web/src/components/ToastProvider.tsx | 128 +++++++++++++++ packages/web/src/lib/api.ts | 7 +- packages/web/src/lib/i18n.tsx | 83 +++++++++- packages/web/src/lib/storage.ts | 61 +++++++ packages/web/src/lib/styles.ts | 74 +++++++++ 10 files changed, 687 insertions(+), 54 deletions(-) create mode 100644 packages/web/src/components/ConfirmDialog.tsx create mode 100644 packages/web/src/components/FullScreenMessage.tsx create mode 100644 packages/web/src/components/Modal.tsx create mode 100644 packages/web/src/components/ToastProvider.tsx create mode 100644 packages/web/src/lib/storage.ts create mode 100644 packages/web/src/lib/styles.ts diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index cbda81e..088e043 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -2,53 +2,64 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; import { AppLayout } from './components/AppLayout'; import { AuthProvider } from './components/AuthProvider'; import { ProtectedRoute } from './components/ProtectedRoute'; +import { ToastProvider } from './components/ToastProvider'; import { LocaleProvider } from './lib/i18n'; import CreateProjectPage from './pages/CreateProjectPage'; import { LoginPage } from './pages/LoginPage'; import ManageProjectsPage from './pages/ManageProjectsPage'; import QRCodesPage from './pages/QRCodesPage'; +/** + * Provider order is load-bearing: + * - ToastProvider below LocaleProvider, so toasts can translate their labels. + * - ToastProvider above AuthProvider, whose 401 handling raises a toast. + * - ToastProvider above Routes, so a success toast fired immediately before + * `navigate('/links')` survives the navigation. That is exactly what "project + * created" needs, and the reason a page-local banner could never deliver it. + */ export default function App() { return ( - - - - } /> - } /> - - - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - - + + + + + } /> + } /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + ); } diff --git a/packages/web/src/components/ConfirmDialog.tsx b/packages/web/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000..4529a6c --- /dev/null +++ b/packages/web/src/components/ConfirmDialog.tsx @@ -0,0 +1,84 @@ +import { Loader2 } from 'lucide-react'; +import { useTranslation } from '../lib/i18n'; +import { btnDestructive, btnSecondary } from '../lib/styles'; +import { Modal } from './Modal'; + +/** + * Confirmation for a destructive action, replacing `window.confirm`. + * + * Three concrete problems with the native dialog here: + * + * 1. It never said *which* item was being deleted. Deleting a project cascades + * to its QR codes and their scan history, and on a phone the Edit and Delete + * buttons sat about 8px apart at 28px tall — so an irreversible action was one + * mis-tap plus a generic "Delete this project?" away. + * 2. Chrome suppresses repeated dialogs ("prevent this page from creating + * additional dialogs"), after which `confirm()` simply returns false and + * delete stops working with no feedback whatsoever. + * 3. It cannot show progress, so there was nothing to disable while the request + * was in flight — a double-tap fired two DELETEs and the second 404'd, showing + * an error *after* a successful delete. + */ +interface ConfirmDialogProps { + open: boolean; + title: string; + /** Should name the specific item, e.g. 「造形大ポスター」を削除しますか? */ + body: string; + confirmLabel: string; + onConfirm: () => void; + onCancel: () => void; + /** True while the action runs: buttons disable and the dialog stops dismissing. */ + pending?: boolean; +} + +export function ConfirmDialog({ + open, + title, + body, + confirmLabel, + onConfirm, + onCancel, + pending = false, +}: ConfirmDialogProps) { + const { t } = useTranslation(); + + return ( + + + + + } + > +

+ {body} +

+
+ ); +} diff --git a/packages/web/src/components/FullScreenMessage.tsx b/packages/web/src/components/FullScreenMessage.tsx new file mode 100644 index 0000000..d2585d6 --- /dev/null +++ b/packages/web/src/components/FullScreenMessage.tsx @@ -0,0 +1,48 @@ +import { Loader2 } from 'lucide-react'; +import { useTranslation } from '../lib/i18n'; +import { btnPrimary } from '../lib/styles'; + +/** + * Full-viewport loading / error state, used before any page chrome exists. + * + * `min-h-dvh` rather than `h-screen`: on iOS Safari the dynamic toolbar makes + * `100vh` taller than the visible area, so the bottom of a centred block gets + * clipped and the page cannot be scrolled to reach it. + */ +export function FullScreenLoading() { + const { t } = useTranslation(); + return ( +
this replaces — a screen reader user + // previously got silence during every page load. + role="status" + aria-live="polite" + className="flex min-h-dvh items-center justify-center gap-2 p-4 text-muted-foreground" + > +
+ ); +} + +export function FullScreenError({ + message, + onRetry, +}: { + message: string; + onRetry?: () => void; +}) { + const { t } = useTranslation(); + return ( +
+

+ {message} +

+ {onRetry ? ( + + ) : null} +
+ ); +} diff --git a/packages/web/src/components/Modal.tsx b/packages/web/src/components/Modal.tsx new file mode 100644 index 0000000..f757087 --- /dev/null +++ b/packages/web/src/components/Modal.tsx @@ -0,0 +1,154 @@ +import { X } from 'lucide-react'; +import { type ReactNode, useCallback, useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import { useTranslation } from '../lib/i18n'; +import { btnIcon } from '../lib/styles'; +import { cn } from '../lib/utils'; + +/** + * A dialog that is actually usable from a keyboard. + * + * The QR dialog it replaces carried `role="dialog" aria-modal="true"` but had no + * focus trap, no autofocus, no Escape handler, no focus restore and no scroll + * lock — Tab walked straight into the page behind the overlay. It also had + * `onKeyDown={(e) => e.stopPropagation()}` on the panel, which *actively* + * prevented key events from bubbling, so any Escape handler added higher up + * could never have fired. + */ +interface ModalProps { + open: boolean; + onClose: () => void; + title: string; + children: ReactNode; + /** Rendered under the body, typically actions. */ + footer?: ReactNode; + /** Suppresses backdrop-click and Escape. Use while a submit is in flight. */ + dismissible?: boolean; + className?: string; +} + +const FOCUSABLE = + 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +export function Modal({ + open, + onClose, + title, + children, + footer, + dismissible = true, + className, +}: ModalProps) { + const { t } = useTranslation(); + const panelRef = useRef(null); + const restoreFocusTo = useRef(null); + + const requestClose = useCallback(() => { + if (dismissible) onClose(); + }, [dismissible, onClose]); + + // Remember what had focus so it can be handed back on close — otherwise focus + // lands on and keyboard users lose their place in the list. + useEffect(() => { + if (!open) return; + restoreFocusTo.current = document.activeElement as HTMLElement | null; + return () => restoreFocusTo.current?.focus?.(); + }, [open]); + + // Lock the page behind the dialog, so scrolling the overlay does not scroll the + // list underneath it. + useEffect(() => { + if (!open) return; + const previous = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = previous; + }; + }, [open]); + + // Move focus into the dialog on open: the first field for a form, the panel + // itself otherwise. + useEffect(() => { + if (!open) return; + const panel = panelRef.current; + if (!panel) return; + const first = panel.querySelector(FOCUSABLE); + (first ?? panel).focus(); + }, [open]); + + // Escape to close, and Tab cycling confined to the panel. + useEffect(() => { + if (!open) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + requestClose(); + return; + } + if (event.key !== 'Tab') return; + const panel = panelRef.current; + if (!panel) return; + const items = Array.from(panel.querySelectorAll(FOCUSABLE)); + if (items.length === 0) return; + const first = items[0]; + const last = items[items.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + // Capture phase, so a child that stops propagation cannot disable Escape. + document.addEventListener('keydown', onKeyDown, true); + return () => document.removeEventListener('keydown', onKeyDown, true); + }, [open, requestClose]); + + if (!open) return null; + + return createPortal( +
+ {/* Presentational backdrop. Keyboard users close with Escape, which is why + this needs no role or key handler of its own. */} + , + document.body, + ); +} diff --git a/packages/web/src/components/ProtectedRoute.tsx b/packages/web/src/components/ProtectedRoute.tsx index 65d32e1..54fda63 100644 --- a/packages/web/src/components/ProtectedRoute.tsx +++ b/packages/web/src/components/ProtectedRoute.tsx @@ -1,19 +1,14 @@ import type { ReactNode } from 'react'; import { Navigate } from 'react-router-dom'; import { useAuthContext } from './AuthProvider'; +import { FullScreenLoading } from './FullScreenMessage'; export function ProtectedRoute({ children }: { children: ReactNode }) { const { user, isLoading } = useAuthContext(); - if (isLoading) { - return ( -
- Loading… -
- ); - } - if (!user) { - return ; - } + // Was a hardcoded English "Loading…" despite common.loading existing — and it + // is the first thing a Japanese user sees on every single page load. + if (isLoading) return ; + if (!user) return ; return <>{children}; } diff --git a/packages/web/src/components/ToastProvider.tsx b/packages/web/src/components/ToastProvider.tsx new file mode 100644 index 0000000..d2da3bf --- /dev/null +++ b/packages/web/src/components/ToastProvider.tsx @@ -0,0 +1,128 @@ +import { X } from 'lucide-react'; +import { + type ReactNode, + createContext, + useCallback, + useContext, + useMemo, + useRef, + useState, +} from 'react'; +import { useTranslation } from '../lib/i18n'; +import { cn } from '../lib/utils'; + +/** + * App-wide notifications. + * + * This is not merely a DRY pass over the four inline error banners. It is the + * only structure that survives the component that raised the message + * unmounting — which was the root cause of the worst bug in the app: cancelling + * a QR code edit mid-save closed the form, and the failure was then written to + * `formError` on a form that no longer rendered. The save failed and the user was + * never told. + * + * It also supplies the success feedback the app had none of: create, edit and + * delete all completed in total silence, which is why "did that work?" was a + * reasonable question to ask of every action. + * + * Placement matters. Bottom-centre on mobile puts messages within thumb reach + * *and* above the fold; the old banners rendered at the very top of the page, so + * a failure while deleting row 9 on a phone was scrolled out of sight and looked + * exactly like nothing happening. + */ + +type ToastKind = 'success' | 'error' | 'info'; + +interface Toast { + id: number; + kind: ToastKind; + message: string; +} + +interface ToastApi { + success: (message: string) => void; + error: (message: string) => void; + info: (message: string) => void; +} + +const ToastContext = createContext(null); + +const KIND_STYLES: Record = { + success: 'border-primary/40 bg-card text-foreground', + error: 'border-destructive/50 bg-destructive/10 text-destructive', + info: 'border-border bg-card text-foreground', +}; + +// Errors linger long enough to read a sentence; confirmations get out of the way. +const DISMISS_MS: Record = { + success: 4000, + error: 8000, + info: 5000, +}; + +export function ToastProvider({ children }: { children: ReactNode }) { + const { t } = useTranslation(); + const [toasts, setToasts] = useState([]); + const nextId = useRef(0); + + const dismiss = useCallback((id: number) => { + setToasts((current) => current.filter((toast) => toast.id !== id)); + }, []); + + const push = useCallback( + (kind: ToastKind, message: string) => { + const id = ++nextId.current; + // Capped at three: a burst of failures (a dead API, say) must not paper + // over the whole screen. + setToasts((current) => [...current.slice(-2), { id, kind, message }]); + window.setTimeout(() => dismiss(id), DISMISS_MS[kind]); + }, + [dismiss], + ); + + const api = useMemo( + () => ({ + success: (message) => push('success', message), + error: (message) => push('error', message), + info: (message) => push('info', message), + }), + [push], + ); + + return ( + + {children} +
+ {toasts.map((toast) => ( +
+ {toast.message} + +
+ ))} +
+
+ ); +} + +export function useToast(): ToastApi { + const ctx = useContext(ToastContext); + if (!ctx) throw new Error('useToast must be used within a ToastProvider'); + return ctx; +} diff --git a/packages/web/src/lib/api.ts b/packages/web/src/lib/api.ts index e91304a..066836a 100644 --- a/packages/web/src/lib/api.ts +++ b/packages/web/src/lib/api.ts @@ -1,17 +1,18 @@ import { TRACKING_LINK_API_URL } from '../config'; +import { safeStorage } from './storage'; const TOKEN_KEY = 'tracking-link.token'; export function getToken(): string | null { - return localStorage.getItem(TOKEN_KEY); + return safeStorage.get(TOKEN_KEY); } export function setToken(token: string): void { - localStorage.setItem(TOKEN_KEY, token); + safeStorage.set(TOKEN_KEY, token); } export function clearToken(): void { - localStorage.removeItem(TOKEN_KEY); + safeStorage.remove(TOKEN_KEY); } /** diff --git a/packages/web/src/lib/i18n.tsx b/packages/web/src/lib/i18n.tsx index 6124e39..fdf5cfd 100644 --- a/packages/web/src/lib/i18n.tsx +++ b/packages/web/src/lib/i18n.tsx @@ -7,6 +7,7 @@ import { useMemo, useState, } from 'react'; +import { safeStorage } from './storage'; export type Locale = 'en' | 'ja'; @@ -36,6 +37,40 @@ const en: Dictionary = { 'common.ip': 'IP', 'common.backToProjects': 'Back to projects', 'common.genericError': 'Something went wrong', + 'common.retry': 'Try again', + 'common.optional': 'optional', + 'common.medium': 'Medium', + 'common.bot': 'Bot', + 'common.deleting': 'Deleting…', + 'common.saving': 'Saving…', + 'common.menu': 'Menu', + + // Errors, keyed off the API's error codes so wording lives on the client. + 'error.network': + 'Could not reach the server. Check your connection and try again.', + 'error.unauthorized': 'Your session has expired. Please sign in again.', + 'error.permission': "You don't have permission to do that.", + 'error.notOwner': 'You can only change QR codes you created.', + 'error.invalidBody': 'Please check the highlighted fields.', + 'error.noFieldsToUpdate': 'Nothing was changed.', + 'error.projectNotFound': + 'That project no longer exists. It may have been deleted.', + 'error.qrNotFound': + 'That QR code no longer exists. It may have been deleted.', + 'error.duplicateName': + 'A QR code with this name already exists in this project.', + 'error.tooManyRows': + 'Too many rows ({total}) to export at once. The limit is {max} — narrow the date range.', + 'error.rateLimited': 'Too many attempts. Please wait a moment and try again.', + 'error.serverError': 'The server had a problem. Please try again.', + + 'validation.required': 'This field is required.', + 'validation.tooLong': 'Please use {max} characters or fewer.', + 'validation.url': 'Enter a URL starting with http:// or https://', + + 'login.sessionExpired': 'Your session expired. Please sign in again.', + 'login.retryAfterNetwork': + 'Could not reach the server. Check your connection.', 'login.subtitle': 'Sign in with the admin password.', 'login.passwordLabel': 'Password', @@ -124,6 +159,42 @@ const ja: Dictionary = { 'common.ip': 'IPアドレス', 'common.backToProjects': 'プロジェクト一覧に戻る', 'common.genericError': 'エラーが発生しました', + 'common.retry': '再試行', + 'common.optional': '任意', + 'common.medium': '媒体', + 'common.bot': 'ボット', + 'common.deleting': '削除中…', + 'common.saving': '保存中…', + 'common.menu': 'メニュー', + + // Errors, keyed off the API's error codes so wording lives on the client. + 'error.network': + 'サーバーに接続できませんでした。通信環境を確認して再試行してください。', + 'error.unauthorized': + 'セッションの有効期限が切れました。再度ログインしてください。', + 'error.permission': 'この操作を行う権限がありません。', + 'error.notOwner': '自分が作成したQRコードのみ変更できます。', + 'error.invalidBody': '入力内容を確認してください。', + 'error.noFieldsToUpdate': '変更点がありません。', + 'error.projectNotFound': + 'このプロジェクトは存在しません。削除された可能性があります。', + 'error.qrNotFound': + 'このQRコードは存在しません。削除された可能性があります。', + 'error.duplicateName': 'この名前のQRコードはこのプロジェクトに既にあります。', + 'error.tooManyRows': + '件数が多すぎます({total}件)。一度に出力できるのは{max}件までです。期間を絞ってください。', + 'error.rateLimited': + '試行回数が多すぎます。しばらく待ってから再試行してください。', + 'error.serverError': 'サーバー側で問題が発生しました。再試行してください。', + + 'validation.required': 'この項目は必須です。', + 'validation.tooLong': '{max}文字以内で入力してください。', + 'validation.url': 'http:// または https:// で始まるURLを入力してください。', + + 'login.sessionExpired': + 'セッションの有効期限が切れました。再度ログインしてください。', + 'login.retryAfterNetwork': + 'サーバーに接続できませんでした。通信環境を確認してください。', 'login.subtitle': '管理者パスワードでログインしてください。', 'login.passwordLabel': 'パスワード', @@ -203,9 +274,15 @@ function interpolate( } function detectDefaultLocale(): Locale { - const stored = localStorage.getItem(LOCALE_KEY); + // safeStorage, not localStorage: this runs inside a useState initialiser, so a + // throw here (Safari Private Browsing, storage blocked) white-screens the app. + const stored = safeStorage.get(LOCALE_KEY); if (stored === 'en' || stored === 'ja') return stored; - return navigator.language.toLowerCase().startsWith('ja') ? 'ja' : 'en'; + try { + return navigator.language.toLowerCase().startsWith('ja') ? 'ja' : 'en'; + } catch { + return 'en'; + } } interface LocaleContextValue { @@ -220,7 +297,7 @@ export function LocaleProvider({ children }: { children: ReactNode }) { const [locale, setLocale] = useState(detectDefaultLocale); useEffect(() => { - localStorage.setItem(LOCALE_KEY, locale); + safeStorage.set(LOCALE_KEY, locale); document.documentElement.lang = locale; }, [locale]); diff --git a/packages/web/src/lib/storage.ts b/packages/web/src/lib/storage.ts new file mode 100644 index 0000000..50a9f8f --- /dev/null +++ b/packages/web/src/lib/storage.ts @@ -0,0 +1,61 @@ +/** + * localStorage that cannot take the app down. + * + * Safari Private Browsing, storage-blocked contexts and a full quota all throw + * on plain `localStorage` access — including the getter. Two call sites made + * that fatal: the token accessors, and `detectDefaultLocale` inside a + * `useState` initialiser, where a throw white-screens the entire app at mount. + * Staff share phones on site and open links in private tabs, so this is a + * realistic way to lose the admin UI completely. + * + * Falls back to an in-memory map: the session and the language switch keep + * working for the life of the tab, they just do not persist. + */ +const memory = new Map(); +let available: boolean | null = null; + +function probe(): boolean { + if (available !== null) return available; + try { + const probeKey = '__tracking_link_probe__'; + window.localStorage.setItem(probeKey, '1'); + window.localStorage.removeItem(probeKey); + available = true; + } catch { + available = false; + } + return available; +} + +export const safeStorage = { + get(key: string): string | null { + if (!probe()) return memory.get(key) ?? null; + try { + return window.localStorage.getItem(key); + } catch { + return memory.get(key) ?? null; + } + }, + + set(key: string, value: string): void { + // The memory copy is written first and unconditionally, so a quota error + // below still leaves the value readable for this tab. + memory.set(key, value); + if (!probe()) return; + try { + window.localStorage.setItem(key, value); + } catch { + /* quota exceeded or blocked — memory copy already holds it */ + } + }, + + remove(key: string): void { + memory.delete(key); + if (!probe()) return; + try { + window.localStorage.removeItem(key); + } catch { + /* nothing useful to do */ + } + }, +}; diff --git a/packages/web/src/lib/styles.ts b/packages/web/src/lib/styles.ts new file mode 100644 index 0000000..169d5df --- /dev/null +++ b/packages/web/src/lib/styles.ts @@ -0,0 +1,74 @@ +/** + * Shared class-name constants. + * + * Deliberately constants rather than ` From b01d14f062d8371a2aa3425994c3471788a1282f Mon Sep 17 00:00:00 2001 From: kamijo-haruto Date: Sat, 25 Jul 2026 18:50:29 +0900 Subject: [PATCH 03/10] =?UTF-8?q?[feat]=20=E5=85=A8=E7=94=BB=E9=9D=A2?= =?UTF-8?q?=E3=82=92=E3=83=A2=E3=83=90=E3=82=A4=E3=83=AB=E5=AF=BE=E5=BF=9C?= =?UTF-8?q?=E3=81=97=E3=83=95=E3=82=A9=E3=83=BC=E3=83=A0=E6=A4=9C=E8=A8=BC?= =?UTF-8?q?=E3=83=BB=E5=89=8A=E9=99=A4=E7=A2=BA=E8=AA=8D=E3=83=BB=E4=B8=80?= =?UTF-8?q?=E8=A6=A7=E5=8F=96=E5=BE=97=E3=82=92=E5=85=B1=E9=80=9A=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3ページとレイアウトを1回で書き換えた(モバイル対応・フォーム検証・トーストが 同じ箇所に集まるため、3回触るより差分が読みやすい)。 ## AppLayout — モバイルの最大の障害 サイドバーが無条件に w-60 で描画され、375pxの端末では240px=画面の64%を占有し、 コンテンツに約135pxしか残っていなかった。スタッフはポスターを貼りながらスマホで 使うので、丁寧に作られた「モバイルカード」レイアウトは実際には与えられていない 幅に押し込められていた。 ハンバーガードロワーではなくトップバーにした。ナビの行き先は2つで、うち /links/create は権限ゲート付きかつ既にページヘッダの主要CTAとして存在する。 つまり実際の中身はブランド・言語・ログアウトの3つで、44pxのターゲットでも56pxの バーに収まる。ドロワーは冗長なリンク1つを出すためにオーバーレイ・フォーカス トラップ・スクロールロック・開閉状態を足すことになる。 同時に h-screen → min-h-dvh(iOSの動的ツールバーによる下端の欠け)、main から overflow-y-auto を削除(ドキュメント自体がスクロールするので window.scrollTo と scrollIntoView が正しく動き、pull-to-refreshも戻る)、min-w-0(長いURLがflex行を 破壊しない)。 ## hooks/useListQuery.ts — ページングの4つのバグ 両リストページにバイト単位で重複していた処理が、4通りに壊れていた: (1) 11件でページ2の唯一の行を削除すると totalPages が1になりページング コンポーネントが null を返すため、「ありません」と表示されたまま**ページ1に 戻る手段が無くなる**(QRページはページングが非空分岐の内側にあり更に悪い)、 (2) `setTotal(total - 1)` が `await fetchData()` の後でサーバーの新しい件数を 古い値由来の数で上書き、(3) AbortControllerも新旧判定も無く、1→3→5と素早く 押すと最後に届いた応答が勝つので選択中のページと中身が食い違う、 (4) アンマウント後の setState。 ## フォーム検証 `required` は空白1文字で満たされ、その後 handleSubmit が trim して裸の return で 抜けていた。空白を1つ入れて保存を押すと**本当に何も起きない**——メッセージも スピナーもフォームの閉じもない。フィールド単位のエラーを出し、最初の不正な フィールドにフォーカスを移し、onBlur で trim するようにした。送信ボタンは 意図的に有効なままにしている(無効化は同じ行き止まりの別の姿)。 ## 削除 window.confirm を ConfirmDialog に置換し、対象名を明示するようにした。 プロジェクト削除はQRコードとアクセスログにカスケードする不可逆操作で、スマホでは 編集と削除が約8px・高さ28pxで隣接していた。実行中の無効化も入れたので、 ダブルタップでDELETEが2回飛んで2回目の404が「削除成功後のエラー」として出る ことも無くなった。 ## QRコードのダウンロード - ファイル名を `ポスター12_ポスター_1F掲示板-12.png` のように名前ベースに (従来は `qr-.png` で、物ごとに1つ作る運用ではファイル名が唯一の識別子)。 - PNGの下部に名前・媒体・場所を焼き込む。印刷後に紙の上でどのポスター用か 分かるのはキャプションだけ。 - data: URL から blob URL へ。iOS Safari では data: URL の download が タブ内遷移になりダイアログの状態を壊す。 - 一覧にサムネイルを追加。同じ見た目の行が並ぶ中で、編集・削除の対象を 確認する最速の手段。 ## その他 - QRDialog を Modal に載せ替え(フォーカストラップ・Esc・スクロールロック・ フォーカス復帰)。medium/location にラベルを付け、モバイルとデスクトップで 食い違っていたボタンラベルと日付の粒度を統一。 - 日付を formatDateTime でアプリのロケールに合わせた。 - location を任意入力にし、ラベルに「(任意)」を付けた。 - 409 で name フィールドを赤枠にする(従来はどこが衝突したか分からなかった)。 - LanguageSwitcher が16px高だったのを44pxにし、aria-pressed と role="group" を追加(選択状態がフォントの太さだけで示されていた)。 - 空状態にアイコンとCTAを追加し、`!error` でゲートした(取得失敗時に エラーと「ありません」が並び、データが消えたように見えていた)。 検証: ヘッドレスChrome(375px/1280px, ja-JP)で実機相当の確認を行った。 サイドバーは375pxで非表示・1280pxで表示、横スクロール0px、44px未満のボタン0個、 サムネイル10件、媒体/場所のラベル表示、aria-current と前後ページのaria-label、 QRダイアログはフォーカスが入りEscで閉じる、ダウンロードは `ポスター12_ポスター_1F掲示板-12.png` で704x788(=キャプション込み)、 名前重複は日本語メッセージ + name の aria-invalid=true、空白のみ入力は 「この項目は必須です。」を表示、場所を空にした作成は成功トースト、 削除確認は「ポスター99」と名前を表示、未捕捉エラー0件。 Co-Authored-By: Claude Opus 5 (1M context) --- packages/web/src/components/AppLayout.tsx | 72 +- .../web/src/components/LanguageSwitcher.tsx | 47 +- packages/web/src/components/Pagination.tsx | 133 ++ packages/web/src/hooks/useFieldErrors.ts | 84 ++ packages/web/src/hooks/useListQuery.ts | 130 ++ packages/web/src/lib/download.ts | 29 + packages/web/src/lib/format.ts | 75 ++ packages/web/src/lib/i18n.tsx | 56 +- packages/web/src/lib/qr.ts | 113 ++ packages/web/src/pages/CreateProjectPage.tsx | 141 ++- packages/web/src/pages/ManageProjectsPage.tsx | 903 ++++++-------- packages/web/src/pages/QRCodesPage.tsx | 1108 +++++++++-------- 12 files changed, 1771 insertions(+), 1120 deletions(-) create mode 100644 packages/web/src/components/Pagination.tsx create mode 100644 packages/web/src/hooks/useFieldErrors.ts create mode 100644 packages/web/src/hooks/useListQuery.ts create mode 100644 packages/web/src/lib/download.ts create mode 100644 packages/web/src/lib/format.ts create mode 100644 packages/web/src/lib/qr.ts diff --git a/packages/web/src/components/AppLayout.tsx b/packages/web/src/components/AppLayout.tsx index 509b702..9991723 100644 --- a/packages/web/src/components/AppLayout.tsx +++ b/packages/web/src/components/AppLayout.tsx @@ -3,18 +3,66 @@ import type { ReactNode } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { Permissions, hasPermission } from '../hooks/useStaffAuth'; import { useTranslation } from '../lib/i18n'; +import { btnIcon } from '../lib/styles'; +import { cn } from '../lib/utils'; import { useAuthContext } from './AuthProvider'; import { LanguageSwitcher } from './LanguageSwitcher'; +/** + * Sidebar on desktop, top app bar on mobile. + * + * The sidebar used to render unconditionally at `w-60`, which on a 375px phone is + * 240px — 64% of the screen — leaving ~135px for content. Staff use this on their + * phones while putting up posters, so every carefully built "mobile card" layout + * was being squeezed into a width the app never actually gave it. + * + * A top bar rather than a hamburger drawer, because there are only two nav + * destinations and one of them (`/links/create`) is permission-gated *and* already + * the primary call to action in the projects page header. So the real payload is + * brand/home, language, logout — three things that fit a 56px bar at full touch + * size. A drawer would add an overlay, focus trap, scroll lock and open/close + * state to reveal one non-redundant link. A bottom tab bar would permanently spend + * vertical space on screens that are lists and forms with a keyboard open, and + * would collide with the toast anchor. + */ export function AppLayout({ children }: { children: ReactNode }) { const { user, logout } = useAuthContext(); const { t } = useTranslation(); const navigate = useNavigate(); const permissions = user?.permissions ?? 0; + const canCreate = hasPermission(permissions, Permissions.TRACKING_LINK_EDIT); + + const onLogout = () => { + logout(); + navigate('/login'); + }; return ( -
-