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と切替を検証
This commit is contained in:
Ken Yasue
2026-06-25 11:37:44 +02:00
parent 6a134a29bd
commit 921cdc457d
74 changed files with 1092 additions and 279 deletions

View File

@ -24,8 +24,8 @@ export function BackupCreateButton() {
}
return (
<div className="rounded-lg border bg-white p-4 shadow-sm">
<p className="text-sm text-gray-600">
<div className="rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm">
<p className="text-sm text-gray-600 dark:text-gray-300">
DBファイル + uploadsディレクトリをZIP化してバックアップを作成します
</p>
<button

View File

@ -58,7 +58,7 @@ export function ThreadForm({ projectId }: { projectId: number }) {
return (
<form
onSubmit={onSubmit}
className="space-y-3 rounded-lg border bg-white p-4 shadow-sm"
className="space-y-3 rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm"
>
<div className="flex gap-2">
<input

View File

@ -40,7 +40,7 @@ export function CalendarEventForm({
return (
<form
onSubmit={onSubmit}
className="flex flex-col gap-2 rounded-lg border bg-white p-4 shadow-sm sm:flex-row sm:items-end"
className="flex flex-col gap-2 rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm sm:flex-row sm:items-end"
>
<div className="flex-1">
<label className="block text-sm font-medium"></label>

View File

@ -80,7 +80,7 @@ export function CalendarView({
<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"
className="text-xl font-bold text-gray-800 dark:text-gray-100"
data-testid="calendar-title"
>
{title}
@ -95,7 +95,7 @@ export function CalendarView({
className={`px-3 py-1 text-sm ${
view === v.mode
? 'bg-blue-600 text-white'
: 'bg-white text-gray-600 hover:bg-gray-100'
: '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}`}
>
@ -107,7 +107,7 @@ export function CalendarView({
<button
type="button"
onClick={goPrev}
className="rounded border bg-white px-2 py-1 text-sm text-gray-600 hover:bg-gray-100"
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"
>
@ -116,7 +116,7 @@ export function CalendarView({
<button
type="button"
onClick={goToday}
className="rounded border bg-white px-3 py-1 text-sm text-gray-600 hover:bg-gray-100"
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"
>
@ -124,7 +124,7 @@ export function CalendarView({
<button
type="button"
onClick={goNext}
className="rounded border bg-white px-2 py-1 text-sm text-gray-600 hover:bg-gray-100"
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"
>

View File

@ -37,14 +37,14 @@ export function DayView({
const isToday = key === todayKey;
return (
<div className="rounded-lg border bg-white">
<div className="rounded-lg border bg-white dark:bg-gray-800">
<div className="flex items-center justify-between border-b p-3">
<button
type="button"
onClick={() => onSelectDate(key)}
className="text-left"
>
<p className="text-lg font-bold text-gray-800">
<p className="text-lg font-bold text-gray-800 dark:text-gray-100">
{day.getMonth() + 1}{day.getDate()}(
{WEEKDAY_LABELS[day.getDay()]})
</p>
@ -58,10 +58,12 @@ export function DayView({
{allDay.length > 0 && (
<div
className="border-b bg-gray-50 p-2"
className="border-b bg-gray-50 dark:bg-gray-900 p-2"
data-testid="calendar-all-day-section"
>
<p className="mb-1 text-xs font-medium text-gray-500"></p>
<p className="mb-1 text-xs font-medium text-gray-500 dark:text-gray-400">
</p>
<div className="flex flex-wrap gap-1">
{allDay.map((e) => (
<button
@ -70,8 +72,9 @@ export function DayView({
onClick={() => onSelectDate(key)}
title={e.title}
className={`max-w-full truncate rounded border px-2 py-0.5 text-xs ${
SOURCE_COLORS[e.source] ?? 'bg-gray-100 text-gray-600'
} ${SOURCE_CHIP_BORDER[e.source] ?? 'border-gray-200'}`}
SOURCE_COLORS[e.source] ??
'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300'
} ${SOURCE_CHIP_BORDER[e.source] ?? 'border-gray-200 dark:border-gray-700'}`}
data-testid={`calendar-event-${e.key}`}
>
{e.title}
@ -90,7 +93,7 @@ export function DayView({
className="flex border-b last:border-b-0"
data-testid={`calendar-hour-${String(h).padStart(2, '0')}`}
>
<div className="w-16 shrink-0 border-r bg-gray-50 p-1 text-right text-xs text-gray-400">
<div className="w-16 shrink-0 border-r bg-gray-50 dark:bg-gray-900 p-1 text-right text-xs text-gray-400 dark:text-gray-500">
{String(h).padStart(2, '0')}:00
</div>
<div className="flex-1 p-1">
@ -101,8 +104,9 @@ export function DayView({
onClick={() => onSelectDate(key)}
title={e.title}
className={`mb-1 block w-full truncate rounded border px-2 py-1 text-left text-xs ${
SOURCE_COLORS[e.source] ?? 'bg-gray-100 text-gray-600'
} ${SOURCE_CHIP_BORDER[e.source] ?? 'border-gray-200'}`}
SOURCE_COLORS[e.source] ??
'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300'
} ${SOURCE_CHIP_BORDER[e.source] ?? 'border-gray-200 dark:border-gray-700'}`}
data-testid={`calendar-event-${e.key}`}
>
<span className="font-mono text-[10px] opacity-70">

View File

@ -51,7 +51,7 @@ export function EventDetailDialog({
<div
ref={dialogRef}
tabIndex={-1}
className="mt-16 w-full max-w-lg rounded-lg bg-white shadow-xl focus:outline-none"
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"
@ -59,11 +59,13 @@ export function EventDetailDialog({
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">{dateLabel}</h2>
<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 hover:bg-gray-100 hover:text-gray-600"
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"
>
@ -72,34 +74,35 @@ export function EventDetailDialog({
</div>
<div className="max-h-[60vh] space-y-3 overflow-y-auto p-4">
{events.length === 0 ? (
<p className="text-sm text-gray-400">
<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 p-3"
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 break-words">
<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 text-gray-600'
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">
<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">
<p className="mt-2 whitespace-pre-wrap text-sm text-gray-600 dark:text-gray-300">
{e.description}
</p>
)}

View File

@ -35,7 +35,7 @@ export function MilestoneForm({ projectId }: { projectId: number }) {
return (
<form
onSubmit={onSubmit}
className="flex flex-col gap-2 rounded-lg border bg-white p-4 shadow-sm sm:flex-row sm:items-end"
className="flex flex-col gap-2 rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm sm:flex-row sm:items-end"
>
<div className="flex-1">
<label className="block text-sm font-medium"></label>

View File

@ -28,8 +28,8 @@ export function MonthView({
onSelectDate: (dateKey: string) => void;
}) {
return (
<div className="overflow-hidden rounded-lg border bg-white">
<div className="grid grid-cols-7 border-b bg-gray-50 text-center text-xs font-medium text-gray-500">
<div className="overflow-hidden rounded-lg border bg-white dark:bg-gray-800">
<div className="grid grid-cols-7 border-b bg-gray-50 dark:bg-gray-900 text-center text-xs font-medium text-gray-500 dark:text-gray-400">
{WEEKDAY_LABELS.map((w) => (
<div key={w} className="py-2">
{w}
@ -50,7 +50,9 @@ export function MonthView({
<div
key={key}
className={`min-h-[110px] border-r border-t p-1 last:border-r-0 ${
inMonth ? 'bg-white' : 'bg-gray-50'
inMonth
? 'bg-white dark:bg-gray-800'
: 'bg-gray-50 dark:bg-gray-900'
}`}
data-testid={`calendar-day-${key}`}
>
@ -61,8 +63,8 @@ export function MonthView({
isToday
? 'bg-blue-600 font-bold text-white'
: inMonth
? 'text-gray-700 hover:bg-gray-100'
: 'text-gray-300 hover:bg-gray-100'
? 'text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700'
: 'text-gray-300 dark:text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
aria-label={`${key} の詳細を開く`}
>
@ -76,8 +78,9 @@ export function MonthView({
onClick={() => onSelectDate(key)}
title={e.title}
className={`block w-full truncate rounded border px-1 text-left text-xs ${
SOURCE_COLORS[e.source] ?? 'bg-gray-100 text-gray-600'
} ${SOURCE_CHIP_BORDER[e.source] ?? 'border-gray-200'}`}
SOURCE_COLORS[e.source] ??
'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300'
} ${SOURCE_CHIP_BORDER[e.source] ?? 'border-gray-200 dark:border-gray-700'}`}
data-testid={`calendar-event-${e.key}`}
>
{e.title}
@ -88,7 +91,7 @@ export function MonthView({
type="button"
onClick={() => onSelectDate(key)}
aria-label={`${key}の残り${hidden}件を開く`}
className="block w-full truncate text-left text-xs text-gray-400 hover:text-gray-600"
className="block w-full truncate text-left text-xs text-gray-400 dark:text-gray-500 hover:text-gray-600"
>
+{hidden}
</button>

View File

@ -23,8 +23,8 @@ export function WeekView({
onSelectDate: (dateKey: string) => void;
}) {
return (
<div className="overflow-hidden rounded-lg border bg-white">
<div className="grid grid-cols-7 border-b bg-gray-50 text-center text-xs font-medium text-gray-500">
<div className="overflow-hidden rounded-lg border bg-white dark:bg-gray-800">
<div className="grid grid-cols-7 border-b bg-gray-50 dark:bg-gray-900 text-center text-xs font-medium text-gray-500 dark:text-gray-400">
{days.map((d) => {
const key = toISODate(d);
return (
@ -32,15 +32,17 @@ export function WeekView({
key={key}
type="button"
onClick={() => onSelectDate(key)}
className="py-2 hover:bg-gray-100"
className="py-2 hover:bg-gray-100 dark:hover:bg-gray-700"
data-testid={`calendar-weekday-${key}`}
>
<div className="text-gray-500">{WEEKDAY_LABELS[d.getDay()]}</div>
<div className="text-gray-500 dark:text-gray-400">
{WEEKDAY_LABELS[d.getDay()]}
</div>
<div
className={`mt-0.5 inline-flex h-6 w-6 items-center justify-center rounded-full text-sm ${
key === todayKey
? 'bg-blue-600 font-bold text-white'
: 'text-gray-700'
: 'text-gray-700 dark:text-gray-200'
}`}
>
{d.getDate()}
@ -61,7 +63,9 @@ export function WeekView({
>
<div className="space-y-1">
{dayEvents.length === 0 ? (
<p className="px-1 py-1 text-xs text-gray-300">-</p>
<p className="px-1 py-1 text-xs text-gray-300 dark:text-gray-600">
-
</p>
) : (
dayEvents.map((e) => (
<button
@ -70,8 +74,9 @@ export function WeekView({
onClick={() => onSelectDate(key)}
title={e.title}
className={`block w-full truncate rounded border px-1 py-0.5 text-left text-xs ${
SOURCE_COLORS[e.source] ?? 'bg-gray-100 text-gray-600'
} ${SOURCE_CHIP_BORDER[e.source] ?? 'border-gray-200'}`}
SOURCE_COLORS[e.source] ??
'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300'
} ${SOURCE_CHIP_BORDER[e.source] ?? 'border-gray-200 dark:border-gray-700'}`}
data-testid={`calendar-event-${e.key}`}
>
<span className="font-mono text-[10px] opacity-70">

View File

@ -94,7 +94,7 @@ export function ChatWindow({ projectId, userName }: ChatWindowProps) {
}
return (
<div className="flex h-[70vh] flex-col rounded-lg border bg-white shadow-sm">
<div className="flex h-[70vh] flex-col rounded-lg border bg-white dark:bg-gray-800 shadow-sm">
<div className="flex-1 overflow-y-auto p-4">
<ul className="space-y-2" data-testid="chat-messages">
{messages.map((m) => (
@ -106,8 +106,8 @@ export function ChatWindow({ projectId, userName }: ChatWindowProps) {
<span
className={`max-w-[80%] rounded-lg px-3 py-2 ${
m.body.includes(`@${userName}`)
? 'bg-yellow-100 text-gray-800'
: 'bg-gray-100 text-gray-800'
? 'bg-yellow-100 text-gray-800 dark:text-gray-100'
: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-100'
}`}
>
{m.body}
@ -117,7 +117,7 @@ export function ChatWindow({ projectId, userName }: ChatWindowProps) {
<AttachmentList attachments={m.attachments} />
</div>
)}
<span className="mt-0.5 text-xs text-gray-400">
<span className="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
{m.createdAt}
</span>
</li>

View File

@ -34,7 +34,7 @@ export function AttachmentList({
return (
<li
key={a.id}
className="overflow-hidden rounded border bg-gray-50"
className="overflow-hidden rounded border bg-gray-50 dark:bg-gray-900"
data-testid={`attachment-${a.id}`}
>
{isImage ? (

View File

@ -98,7 +98,7 @@ export const AttachmentPicker = forwardRef<
return (
<div className="space-y-2" data-testid="attachment-picker">
<label className="inline-flex cursor-pointer items-center gap-1 rounded border bg-white px-3 py-1 text-sm text-gray-600 hover:bg-gray-100">
<label className="inline-flex cursor-pointer items-center gap-1 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">
📎
<input
ref={inputRef}
@ -111,7 +111,10 @@ export const AttachmentPicker = forwardRef<
/>
</label>
{loading && (
<p className="text-xs text-gray-500" data-testid="attachment-loading">
<p
className="text-xs text-gray-500 dark:text-gray-400"
data-testid="attachment-loading"
>
...
</p>
)}
@ -128,7 +131,7 @@ export const AttachmentPicker = forwardRef<
return (
<li
key={f.fileId}
className="relative overflow-hidden rounded border bg-gray-50"
className="relative overflow-hidden rounded border bg-gray-50 dark:bg-gray-900"
data-testid={`attachment-picked-${f.fileId}`}
>
{isImage ? (

View File

@ -20,7 +20,11 @@ export function FileList({
}
if (files.length === 0) {
return <p className="text-sm text-gray-400"></p>;
return (
<p className="text-sm text-gray-400 dark:text-gray-500">
</p>
);
}
return (
@ -36,7 +40,7 @@ export function FileList({
return (
<li
key={file.id}
className="rounded-lg border bg-white p-3 shadow-sm"
className="rounded-lg border bg-white dark:bg-gray-800 p-3 shadow-sm"
data-testid={`file-item-${file.id}`}
>
{isImage ? (
@ -56,25 +60,27 @@ export function FileList({
href={url}
target="_blank"
rel="noreferrer"
className="flex h-24 items-center justify-center rounded bg-gray-100 text-sm text-blue-600 hover:underline"
className="flex h-24 items-center justify-center rounded bg-gray-100 dark:bg-gray-700 text-sm text-blue-600 hover:underline"
>
PDF
</a>
) : (
<a
href={url}
className="flex h-24 items-center justify-center rounded bg-gray-100 text-sm text-blue-600 hover:underline"
className="flex h-24 items-center justify-center rounded bg-gray-100 dark:bg-gray-700 text-sm text-blue-600 hover:underline"
>
</a>
)}
<p
className="mt-2 truncate text-xs text-gray-700"
className="mt-2 truncate text-xs text-gray-700 dark:text-gray-200"
title={file.originalName}
>
{file.originalName}
</p>
<p className="text-xs text-gray-400">{file.size} bytes</p>
<p className="text-xs text-gray-400 dark:text-gray-500">
{file.size} bytes
</p>
{canDelete && (
<button
type="button"

View File

@ -33,7 +33,7 @@ export function Uploader({ projectId }: { projectId: number }) {
}
return (
<div className="rounded-lg border bg-white p-4 shadow-sm">
<div className="rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm">
<label className="block text-sm font-medium">
</label>
@ -46,7 +46,9 @@ export function Uploader({ projectId }: { projectId: number }) {
data-testid="file-input"
/>
{loading && (
<p className="mt-1 text-sm text-gray-500">...</p>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
...
</p>
)}
{error && (
<p className="mt-1 text-sm text-red-600" role="alert">

View File

@ -3,9 +3,12 @@
import { useRouter } from 'next/navigation';
import type { PublicUser } from '@/lib/auth/getCurrentUser';
import { NotificationBadge } from '@/components/notifications/NotificationBadge';
import { ThemeToggle } from '@/components/layout/ThemeToggle';
import { useI18n } from '@/lib/i18n/I18nProvider';
export function Header({ user }: { user: PublicUser }) {
const router = useRouter();
const { t } = useI18n();
async function handleLogout() {
await fetch('/api/auth/logout', { method: 'POST' });
@ -14,24 +17,34 @@ export function Header({ user }: { user: PublicUser }) {
}
return (
<header className="flex items-center justify-between border-b bg-white px-6 py-3">
<a href="/dashboard" className="font-bold text-gray-800">
<header className="flex items-center justify-between border-b bg-white px-6 py-3 dark:border-gray-700 dark:bg-gray-800">
<a
href="/dashboard"
className="font-bold text-gray-800 dark:text-gray-100"
>
{t('app.name')}
</a>
<nav className="flex items-center gap-4 text-sm">
<a href="/dashboard" className="text-gray-600 hover:underline">
<a
href="/dashboard"
className="text-gray-600 hover:underline dark:text-gray-300"
>
{t('nav.dashboard')}
</a>
<ThemeToggle />
<NotificationBadge />
<a href="/profile" className="text-gray-600 hover:underline">
{user.name}
<a
href="/profile"
className="text-gray-600 hover:underline dark:text-gray-300"
>
{user.name}
</a>
<button
type="button"
onClick={handleLogout}
className="text-blue-600 hover:underline"
className="text-blue-600 hover:underline dark:text-blue-400"
>
{t('header.logout')}
</button>
</nav>
</header>

View File

@ -1,18 +1,23 @@
const NAV_ITEMS = [
{ href: '', label: '概要' },
{ href: '/board', label: '掲示板' },
{ href: '/notes', label: 'メモ' },
{ href: '/chat', label: 'チャット' },
{ href: '/todos', label: 'ToDo' },
{ href: '/files', label: 'ファイル' },
{ href: '/calendar', label: 'カレンダー' },
{ href: '/milestones', label: 'マイルストーン' },
{ href: '/meetings', label: 'ミーティング' },
{ href: '/search', label: '検索' },
{ href: '/members', label: 'メンバー' },
{ href: '/activity', label: 'アクティビティ' },
{ href: '/settings', label: '設定' },
] as const;
'use client';
import { useI18n } from '@/lib/i18n/I18nProvider';
import type { MessageKey } from '@/lib/i18n/dictionary';
const NAV_ITEMS: { href: string; key: MessageKey; activeKey: string }[] = [
{ href: '', key: 'nav.overview', activeKey: 'overview' },
{ href: '/board', key: 'nav.board', activeKey: 'board' },
{ href: '/notes', key: 'nav.notes', activeKey: 'notes' },
{ href: '/chat', key: 'nav.chat', activeKey: 'chat' },
{ href: '/todos', key: 'nav.todos', activeKey: 'todos' },
{ href: '/files', key: 'nav.files', activeKey: 'files' },
{ href: '/calendar', key: 'nav.calendar', activeKey: 'calendar' },
{ href: '/milestones', key: 'nav.milestones', activeKey: 'milestones' },
{ href: '/meetings', key: 'nav.meetings', activeKey: 'meetings' },
{ href: '/search', key: 'nav.search', activeKey: 'search' },
{ href: '/members', key: 'nav.members', activeKey: 'members' },
{ href: '/activity', key: 'nav.activity', activeKey: 'activity' },
{ href: '/settings', key: 'nav.settings', activeKey: 'settings' },
];
/**
* プロジェクト内サブナビゲーション。
@ -38,38 +43,23 @@ export function ProjectNav({
| 'activity'
| 'settings';
}) {
const activeMap: Record<string, boolean> = {
overview: active === 'overview',
board: active === 'board',
notes: active === 'notes',
chat: active === 'chat',
todos: active === 'todos',
files: active === 'files',
calendar: active === 'calendar',
milestones: active === 'milestones',
meetings: active === 'meetings',
search: active === 'search',
members: active === 'members',
activity: active === 'activity',
settings: active === 'settings',
};
const { t } = useI18n();
return (
<nav className="flex gap-4 border-b bg-white px-6 py-2 text-sm">
<nav className="flex gap-4 border-b bg-white px-6 py-2 text-sm dark:border-gray-700 dark:bg-gray-800">
{NAV_ITEMS.map((item) => {
const key = item.href === '' ? 'overview' : item.href.slice(1);
const href = `/projects/${projectId}${item.href}`;
return (
<a
key={item.href}
href={href}
className={
activeMap[key]
? 'font-medium text-blue-600'
: 'text-gray-600 hover:underline'
active === item.activeKey
? 'font-medium text-blue-600 dark:text-blue-400'
: 'text-gray-600 hover:underline dark:text-gray-300'
}
>
{item.label}
{t(item.key)}
</a>
);
})}

View File

@ -4,6 +4,8 @@
*/
export function Sidebar({ children }: { children: React.ReactNode }) {
return (
<aside className="w-48 shrink-0 border-r bg-gray-50 p-4">{children}</aside>
<aside className="w-48 shrink-0 border-r bg-gray-50 dark:bg-gray-900 p-4">
{children}
</aside>
);
}

View File

@ -0,0 +1,24 @@
'use client';
import { useI18n } from '@/lib/i18n/I18nProvider';
/**
* ヘッダー等に置くテーマ切替ボタン。
* クリックで dark/light を即時切替(Cookie + ユーザー設定へ永続化)。
*/
export function ThemeToggle() {
const { theme, setTheme } = useI18n();
const next = theme === 'dark' ? 'light' : 'dark';
return (
<button
type="button"
onClick={() => void setTheme(next)}
aria-label={theme === 'dark' ? 'Switch to light' : 'Switch to dark'}
data-testid="theme-toggle"
className="rounded px-2 py-1 text-sm text-gray-600 hover:underline dark:text-gray-300"
>
{theme === 'dark' ? '☀️' : '🌙'}
</button>
);
}

View File

@ -69,7 +69,7 @@ export function MeetingForm({
return (
<form
onSubmit={onSubmit}
className="space-y-3 rounded-lg border bg-white p-4 shadow-sm"
className="space-y-3 rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm"
data-testid="meeting-form"
>
<div>

View File

@ -50,9 +50,11 @@ export function NoteEditor({
return (
<form
onSubmit={onSubmit}
className="space-y-3 rounded-lg border bg-white p-4 shadow-sm"
className="space-y-3 rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm"
>
<h2 className="text-sm font-semibold text-gray-700"></h2>
<h2 className="text-sm font-semibold text-gray-700 dark:text-gray-200">
</h2>
<input
type="text"
value={title}

View File

@ -36,7 +36,7 @@ export function NoteForm({ projectId }: { projectId: number }) {
return (
<form
onSubmit={onSubmit}
className="space-y-3 rounded-lg border bg-white p-4 shadow-sm"
className="space-y-3 rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm"
>
<input
type="text"

View File

@ -23,7 +23,7 @@ export function MarkReadButton({ notificationId }: { notificationId: number }) {
type="button"
onClick={onRead}
disabled={busy}
className="text-xs text-gray-500 hover:underline disabled:opacity-50"
className="text-xs text-gray-500 dark:text-gray-400 hover:underline disabled:opacity-50"
>
{busy ? '処理中...' : '既読にする'}
</button>

View File

@ -22,7 +22,10 @@ export function NotificationBadge() {
if (count === 0) {
return (
<a href="/notifications" className="text-gray-600 hover:underline">
<a
href="/notifications"
className="text-gray-600 dark:text-gray-300 hover:underline"
>
</a>
);
@ -31,7 +34,7 @@ export function NotificationBadge() {
return (
<a
href="/notifications"
className="relative text-gray-600 hover:underline"
className="relative text-gray-600 dark:text-gray-300 hover:underline"
data-notification-count={count}
>

View File

@ -18,11 +18,15 @@ export function NotificationList({
notifications: Notification[];
}) {
if (notifications.length === 0) {
return <p className="text-sm text-gray-400"></p>;
return (
<p className="text-sm text-gray-400 dark:text-gray-500">
</p>
);
}
return (
<ul className="divide-y rounded-lg border bg-white shadow-sm">
<ul className="divide-y rounded-lg border bg-white dark:bg-gray-800 shadow-sm">
{notifications.map((notification) => (
<li key={notification.id} className="p-4">
<div className="flex items-center justify-between">
@ -31,9 +35,13 @@ export function NotificationList({
</span>
<MarkReadButton notificationId={notification.id} />
</div>
<p className="mt-2 font-medium text-gray-800">{notification.title}</p>
<p className="mt-2 font-medium text-gray-800 dark:text-gray-100">
{notification.title}
</p>
{notification.body && (
<p className="mt-1 text-sm text-gray-600">{notification.body}</p>
<p className="mt-1 text-sm text-gray-600 dark:text-gray-300">
{notification.body}
</p>
)}
{notification.projectId !== null && (
<a

View File

@ -37,7 +37,7 @@ export function AddMemberForm({ projectId }: { projectId: number }) {
return (
<form
onSubmit={onSubmit}
className="flex flex-col gap-2 rounded border bg-white p-4 sm:flex-row sm:items-end"
className="flex flex-col gap-2 rounded border bg-white dark:bg-gray-800 p-4 sm:flex-row sm:items-end"
>
<div className="flex-1">
<label htmlFor="member-email" className="block text-sm font-medium">

View File

@ -2,9 +2,11 @@
import { useState, type FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import { useI18n } from '@/lib/i18n/I18nProvider';
export function CreateProjectForm() {
const router = useRouter();
const { t } = useI18n();
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [error, setError] = useState<string | null>(null);
@ -30,25 +32,25 @@ export function CreateProjectForm() {
const body = (await res.json().catch(() => null)) as {
error?: { message?: string };
} | null;
setError(body?.error?.message ?? 'プロジェクトの作成に失敗しました');
setError(body?.error?.message ?? t('project.createFailed'));
setLoading(false);
}
return (
<form
onSubmit={onSubmit}
className="flex flex-col gap-2 rounded-lg border bg-white p-4 shadow-sm sm:flex-row sm:items-end"
className="flex flex-col gap-2 rounded-lg border bg-white p-4 shadow-sm sm:flex-row sm:items-end dark:border-gray-700 dark:bg-gray-800"
>
<div className="flex-1">
<label htmlFor="project-name" className="block text-sm font-medium">
{t('auth.projectName')}
</label>
<input
id="project-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
className="mt-1 w-full rounded border px-3 py-2 dark:border-gray-600 dark:bg-gray-700"
required
maxLength={200}
/>
@ -58,14 +60,14 @@ export function CreateProjectForm() {
htmlFor="project-description"
className="block text-sm font-medium"
>
{t('project.description')}
</label>
<input
id="project-description"
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
className="mt-1 w-full rounded border px-3 py-2 dark:border-gray-600 dark:bg-gray-700"
/>
</div>
<button
@ -73,7 +75,7 @@ export function CreateProjectForm() {
disabled={loading}
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? '作成中...' : '新規プロジェクト'}
{loading ? t('project.creating') : t('auth.newProject')}
</button>
{error && (
<p className="text-sm text-red-600 sm:full" role="alert">

View File

@ -12,12 +12,16 @@ export function DashboardWidget({
empty?: string;
}) {
return (
<section className="rounded-lg border bg-white p-4 shadow-sm">
<h2 className="mb-3 text-sm font-semibold text-gray-700">{title}</h2>
<section className="rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm">
<h2 className="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-200">
{title}
</h2>
{children ? (
<div className="space-y-2">{children}</div>
) : (
<p className="text-sm text-gray-400">{empty ?? 'データがありません'}</p>
<p className="text-sm text-gray-400 dark:text-gray-500">
{empty ?? 'データがありません'}
</p>
)}
</section>
);

View File

@ -11,27 +11,30 @@ const STATUS_COLORS: Record<string, string> = {
active: 'bg-green-100 text-green-800',
on_hold: 'bg-yellow-100 text-yellow-800',
completed: 'bg-blue-100 text-blue-800',
archived: 'bg-gray-200 text-gray-700',
archived: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200',
};
export function ProjectCard({ project }: { project: Project }) {
return (
<a
href={`/projects/${project.id}`}
className="block rounded-lg border bg-white p-4 shadow-sm transition hover:shadow-md"
className="block rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm transition hover:shadow-md"
>
<div className="flex items-center justify-between">
<h3 className="font-semibold text-gray-800">{project.name}</h3>
<h3 className="font-semibold text-gray-800 dark:text-gray-100">
{project.name}
</h3>
<span
className={`rounded px-2 py-0.5 text-xs ${
STATUS_COLORS[project.status] ?? 'bg-gray-100 text-gray-700'
STATUS_COLORS[project.status] ??
'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200'
}`}
>
{STATUS_LABELS[project.status] ?? project.status}
</span>
</div>
{project.description && (
<p className="mt-2 line-clamp-2 text-sm text-gray-600">
<p className="mt-2 line-clamp-2 text-sm text-gray-600 dark:text-gray-300">
{project.description}
</p>
)}

View File

@ -49,7 +49,7 @@ export function ProjectSettingsForm({
if (!canManage) {
return (
<p className="text-sm text-gray-500">
<p className="text-sm text-gray-500 dark:text-gray-400">
</p>
);

View File

@ -39,7 +39,7 @@ export function SearchForm({
return (
<form
onSubmit={onSubmit}
className="flex flex-col gap-2 rounded-lg border bg-white p-4 shadow-sm sm:flex-row"
className="flex flex-col gap-2 rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm sm:flex-row"
data-testid="search-form"
>
<input

View File

@ -8,7 +8,7 @@ import { TodoDialog } from '@/components/todo/TodoDialog';
const PRIORITY_COLORS: Record<string, string> = {
high: 'bg-red-100 text-red-700',
normal: 'bg-yellow-100 text-yellow-700',
low: 'bg-gray-100 text-gray-600',
low: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300',
};
export function KanbanBoard({
@ -88,13 +88,13 @@ export function KanbanBoard({
return (
<section
key={column.id}
className="w-64 shrink-0 rounded-lg bg-gray-100 p-3"
className="w-64 shrink-0 rounded-lg bg-gray-100 dark:bg-gray-700 p-3"
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => onDrop(e, column)}
data-testid={`kanban-column-${column.id}`}
data-column-id={column.id}
>
<h2 className="mb-2 text-sm font-semibold text-gray-700">
<h2 className="mb-2 text-sm font-semibold text-gray-700 dark:text-gray-200">
{column.name} ({colItems.length})
</h2>
<ul className="space-y-2">
@ -112,7 +112,7 @@ export function KanbanBoard({
draggable
onDragStart={(e) => onDragStart(e, item.id)}
onClick={() => setSelectedItem(item)}
className={`cursor-grab rounded border bg-white p-2 shadow-sm ${
className={`cursor-grab rounded border bg-white dark:bg-gray-800 p-2 shadow-sm ${
draggingId === item.id ? 'opacity-50' : ''
} ${item.completedAt ? 'opacity-60 line-through' : ''}`}
data-testid={`todo-card-${item.id}`}
@ -146,17 +146,17 @@ export function KanbanBoard({
{item.priority}
</span>
{item.dueDate && (
<span className="text-xs text-gray-500">
<span className="text-xs text-gray-500 dark:text-gray-400">
: {item.dueDate}
</span>
)}
{item.startDate && (
<span className="text-xs text-gray-400">
<span className="text-xs text-gray-400 dark:text-gray-500">
: {item.startDate}
</span>
)}
{assignee && (
<span className="text-xs text-gray-500">
<span className="text-xs text-gray-500 dark:text-gray-400">
@{assignee.user.name}
</span>
)}
@ -166,7 +166,7 @@ export function KanbanBoard({
{tags.map((t) => (
<span
key={t}
className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-600"
className="rounded bg-gray-100 dark:bg-gray-700 px-1.5 py-0.5 text-[10px] text-gray-600 dark:text-gray-300"
>
{t}
</span>

View File

@ -131,7 +131,7 @@ export function TodoDialog({
data-testid="todo-dialog-backdrop"
>
<div
className="mt-8 w-full max-w-xl rounded-lg bg-white shadow-xl"
className="mt-8 w-full max-w-xl rounded-lg bg-white dark:bg-gray-800 shadow-xl"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
@ -139,11 +139,13 @@ export function TodoDialog({
data-testid="todo-dialog"
>
<div className="flex items-center justify-between border-b p-4">
<h2 className="text-lg font-bold text-gray-800">ToDoの編集</h2>
<h2 className="text-lg font-bold text-gray-800 dark:text-gray-100">
ToDoの編集
</h2>
<button
type="button"
onClick={onClose}
className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
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="todo-dialog-close"
>
@ -152,7 +154,9 @@ export function TodoDialog({
</div>
{loading ? (
<p className="p-6 text-sm text-gray-400">...</p>
<p className="p-6 text-sm text-gray-400 dark:text-gray-500">
...
</p>
) : (
<div className="max-h-[70vh] space-y-3 overflow-y-auto p-4">
<div>
@ -248,7 +252,7 @@ export function TodoDialog({
{tagList.map((t) => (
<span
key={t}
className="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600"
className="rounded bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs text-gray-600 dark:text-gray-300"
>
{t}
</span>
@ -262,7 +266,9 @@ export function TodoDialog({
{attachments.length > 0 ? (
<AttachmentList attachments={attachments} />
) : (
<p className="text-xs text-gray-400"></p>
<p className="text-xs text-gray-400 dark:text-gray-500">
</p>
)}
<div className="mt-2">
<AttachmentPicker
@ -273,7 +279,7 @@ export function TodoDialog({
</div>
</div>
<div className="space-y-1 border-t pt-3 text-xs text-gray-500">
<div className="space-y-1 border-t pt-3 text-xs text-gray-500 dark:text-gray-400">
<p>: {current.completedAt ?? '未完了'}</p>
<p>: {current.createdAt}</p>
<p>: {current.updatedAt}</p>
@ -291,7 +297,7 @@ export function TodoDialog({
<button
type="button"
onClick={onClose}
className="rounded border px-4 py-2 text-sm text-gray-600 hover:bg-gray-100"
className="rounded border px-4 py-2 text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
>
</button>