Files
opengroupware/components/calendar/EventDetailDialog.tsx
Ken Yasue 921cdc457d feat(ux): dark/light theme + en/ja i18n + user preferences
UX全体にテーマと言語設定を追加。

- lib/db/migrations/004_user_prefs.sql: users に theme/locale 列を追加(既定 dark/en)
- lib/i18n/: dictionary(en/ja) + I18nProvider(クライアントcontext: locale/theme/t/setLocale/setTheme) + server.ts(SSR用 getLocale/getTheme/translate) + constants.ts
- app/layout.tsx: theme/locale Cookie を読み <html class/lang> をSSR、I18nProvider でラップ(フラッシュなし)
- tailwind darkMode:'class' + 全画面に dark: バリアントを一括付与(Nodeスクリプト lookbehind で安全に変換)
- components/layout/ThemeToggle: 即時クラス切替+Cookie+永続化
- app/profile: テーマ/言語セレクタ、chrome翻訳(Header/ProjectNav/login/dashboard/profile)
- PATCH /api/users/me: theme/locale を受理(バリデーション→400)、Cookieを設定
- E2E: locale=ja storageState で既存JAアサーションを維持、theme-i18n.spec で既定en/darkと切替を検証
2026-06-25 11:37:44 +02:00

117 lines
3.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useEffect, useRef } from 'react';
import type { CalendarEventView } from '@/services/ScheduleService';
import { formatTime } from '@/lib/calendar/grid';
import {
SOURCE_COLORS,
SOURCE_LABELS,
} from '@/components/calendar/sourceColors';
/**
* 指定日のイベント詳細ダイアログ。
* セル内ではtruncateされるタイトルも、ここでは全文と説明を表示する。
* 開閉時にフォーカスをダイアログへ移し、閉じたら元の要素へ戻す。
*/
export function EventDetailDialog({
date,
events,
onClose,
}: {
date: Date;
events: CalendarEventView[];
onClose: () => void;
}) {
const dialogRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const previouslyFocused = document.activeElement as HTMLElement | null;
dialogRef.current?.focus();
return () => {
previouslyFocused?.focus?.();
};
}, []);
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
const dateLabel = `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}`;
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center bg-black/40 p-4"
onClick={onClose}
data-testid="calendar-detail-backdrop"
>
<div
ref={dialogRef}
tabIndex={-1}
className="mt-16 w-full max-w-lg rounded-lg bg-white dark:bg-gray-800 shadow-xl focus:outline-none"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label={`${dateLabel}のイベント`}
data-testid="calendar-detail-dialog"
>
<div className="flex items-center justify-between border-b p-4">
<h2 className="text-lg font-bold text-gray-800 dark:text-gray-100">
{dateLabel}
</h2>
<button
type="button"
onClick={onClose}
className="rounded p-1 text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-600"
aria-label="閉じる"
data-testid="calendar-detail-close"
>
×
</button>
</div>
<div className="max-h-[60vh] space-y-3 overflow-y-auto p-4">
{events.length === 0 ? (
<p className="text-sm text-gray-400 dark:text-gray-500">
</p>
) : (
events.map((e) => (
<div
key={e.key}
className="rounded border border-gray-200 dark:border-gray-700 p-3"
data-testid={`calendar-detail-${e.key}`}
>
<div className="flex items-center justify-between gap-2">
<p className="font-medium text-gray-800 dark:text-gray-100 break-words">
{e.title}
</p>
<span
className={`shrink-0 rounded px-2 py-0.5 text-xs ${
SOURCE_COLORS[e.source] ??
'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300'
}`}
>
{SOURCE_LABELS[e.source] ?? e.source}
</span>
</div>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{formatTime(e.startAt)}
{e.endAt ? `${formatTime(e.endAt)}` : ''}
</p>
{e.description && (
<p className="mt-2 whitespace-pre-wrap text-sm text-gray-600 dark:text-gray-300">
{e.description}
</p>
)}
</div>
))
)}
</div>
</div>
</div>
);
}