Files
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

186 lines
5.5 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 { useState } from 'react';
import { useRouter } from 'next/navigation';
import type { CalendarEventView } from '@/services/ScheduleService';
import {
getMonthGrid,
getWeekDays,
parseISODate,
stepAnchor,
toISODate,
eventsOnDay,
type CalendarViewMode,
} from '@/lib/calendar/grid';
import { WEEKDAY_LABELS } from '@/components/calendar/sourceColors';
import { MonthView } from '@/components/calendar/MonthView';
import { WeekView } from '@/components/calendar/WeekView';
import { DayView } from '@/components/calendar/DayView';
import { EventDetailDialog } from '@/components/calendar/EventDetailDialog';
const VIEW_LABELS: { mode: CalendarViewMode; label: string }[] = [
{ mode: 'month', label: '月' },
{ mode: 'week', label: '週' },
{ mode: 'day', label: '日' },
];
/**
* カレンダーUIのクライアントコンテナ。
* 表示モード切替・前/次/今日ナビ・詳細ダイアログを管理し、
* データ取得はURL searchParams経由でServer Componentに委ねる。
*/
export function CalendarView({
projectId,
events,
view,
anchorDate,
todayKey,
}: {
projectId: number;
events: CalendarEventView[];
view: CalendarViewMode;
anchorDate: string;
todayKey: string;
}) {
const router = useRouter();
const [selectedDateKey, setSelectedDateKey] = useState<string | null>(null);
const anchor = parseISODate(anchorDate);
function navigate(nextView: CalendarViewMode, nextAnchor: Date) {
const params = new URLSearchParams({
view: nextView,
date: toISODate(nextAnchor),
});
router.push(`/projects/${projectId}/calendar?${params.toString()}`);
}
function changeView(nextView: CalendarViewMode) {
navigate(nextView, anchor);
}
function goPrev() {
navigate(view, stepAnchor(view, anchor, -1));
}
function goNext() {
navigate(view, stepAnchor(view, anchor, 1));
}
function goToday() {
navigate(view, new Date());
}
function openDetail(dateKey: string) {
setSelectedDateKey(dateKey);
}
const title = buildTitle(view, anchor);
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2
className="text-xl font-bold text-gray-800 dark:text-gray-100"
data-testid="calendar-title"
>
{title}
</h2>
<div className="flex items-center gap-2">
<div className="flex overflow-hidden rounded border">
{VIEW_LABELS.map((v) => (
<button
key={v.mode}
type="button"
onClick={() => changeView(v.mode)}
className={`px-3 py-1 text-sm ${
view === v.mode
? 'bg-blue-600 text-white'
: 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
data-testid={`calendar-view-${v.mode}`}
>
{v.label}
</button>
))}
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={goPrev}
className="rounded border bg-white dark:bg-gray-800 px-2 py-1 text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
aria-label="前へ"
data-testid="calendar-prev"
>
</button>
<button
type="button"
onClick={goToday}
className="rounded border bg-white dark:bg-gray-800 px-3 py-1 text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
data-testid="calendar-today"
>
</button>
<button
type="button"
onClick={goNext}
className="rounded border bg-white dark:bg-gray-800 px-2 py-1 text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
aria-label="次へ"
data-testid="calendar-next"
>
</button>
</div>
</div>
</div>
{view === 'month' && (
<MonthView
events={events}
weeks={getMonthGrid(anchor.getFullYear(), anchor.getMonth())}
anchorMonth={anchor.getMonth()}
todayKey={todayKey}
onSelectDate={openDetail}
/>
)}
{view === 'week' && (
<WeekView
events={events}
days={getWeekDays(anchor)}
todayKey={todayKey}
onSelectDate={openDetail}
/>
)}
{view === 'day' && (
<DayView
day={anchor}
events={events}
todayKey={todayKey}
onSelectDate={openDetail}
/>
)}
{selectedDateKey && (
<EventDetailDialog
date={parseISODate(selectedDateKey)}
events={eventsOnDay(events, selectedDateKey)}
onClose={() => setSelectedDateKey(null)}
/>
)}
</div>
);
}
function buildTitle(view: CalendarViewMode, anchor: Date): string {
if (view === 'month') {
return `${anchor.getFullYear()}${anchor.getMonth() + 1}`;
}
if (view === 'week') {
const days = getWeekDays(anchor);
const s = days[0];
const e = days[6];
return `${s.getFullYear()}${s.getMonth() + 1}${s.getDate()}日 〜 ${e.getMonth() + 1}${e.getDate()}`;
}
return `${anchor.getFullYear()}${anchor.getMonth() + 1}${anchor.getDate()}日(${WEEKDAY_LABELS[anchor.getDay()]})`;
}