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:
@ -22,12 +22,16 @@ test.describe('chat (SSE realtime)', () => {
|
||||
const memberEmail = unique('member') + '@example.com';
|
||||
|
||||
// メンバーを先に登録(UIで作成+ログイン)
|
||||
const memberContext = await browser.newContext();
|
||||
const memberContext = await browser.newContext({
|
||||
storageState: 'tests/e2e/locale-ja.json',
|
||||
});
|
||||
const memberPage = await memberContext.newPage();
|
||||
await registerAndLogin(memberPage, memberEmail, 'Member');
|
||||
|
||||
// オーナー登録+プロジェクト作成
|
||||
const ownerContext = await browser.newContext();
|
||||
const ownerContext = await browser.newContext({
|
||||
storageState: 'tests/e2e/locale-ja.json',
|
||||
});
|
||||
const ownerPage = await ownerContext.newPage();
|
||||
await registerAndLogin(ownerPage, ownerEmail, 'Owner');
|
||||
await ownerPage.getByLabel('プロジェクト名').fill(unique('Proj'));
|
||||
|
||||
15
tests/e2e/locale-ja.json
Normal file
15
tests/e2e/locale-ja.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"cookies": [
|
||||
{
|
||||
"name": "locale",
|
||||
"value": "ja",
|
||||
"domain": "localhost",
|
||||
"path": "/",
|
||||
"expires": -1,
|
||||
"httpOnly": false,
|
||||
"secure": false,
|
||||
"sameSite": "Lax"
|
||||
}
|
||||
],
|
||||
"origins": []
|
||||
}
|
||||
75
tests/e2e/theme-i18n.spec.ts
Normal file
75
tests/e2e/theme-i18n.spec.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
function unique(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`;
|
||||
}
|
||||
|
||||
async function setupOwner(page: Page): Promise<number> {
|
||||
const email = unique('owner') + '@example.com';
|
||||
await page.goto('/login');
|
||||
await page.getByRole('button', { name: '新規登録はこちら' }).click();
|
||||
await page.getByLabel('表示名').fill('Owner');
|
||||
await page.getByLabel('メールアドレス').fill(email);
|
||||
await page.getByLabel('パスワード').fill('password123');
|
||||
await page.getByRole('button', { name: '登録する' }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
await page.getByLabel('プロジェクト名').fill(unique('Proj'));
|
||||
await page.getByRole('button', { name: '新規プロジェクト' }).click();
|
||||
await expect(page).toHaveURL(/\/projects\/\d+$/);
|
||||
return Number(page.url().match(/\/projects\/(\d+)/)![1]);
|
||||
}
|
||||
|
||||
test.describe('theme & i18n', () => {
|
||||
test('a fresh visitor gets English locale and dark theme by default', async ({
|
||||
browser,
|
||||
}) => {
|
||||
// storageState を持たない = 初回訪問者(既定 en / dark)
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
await page.goto('/login');
|
||||
await expect(page.getByRole('heading', { name: 'Login' })).toBeVisible();
|
||||
const className = await page.evaluate(
|
||||
() => document.documentElement.className
|
||||
);
|
||||
expect(className).toContain('dark');
|
||||
await ctx.close();
|
||||
});
|
||||
|
||||
test('theme toggle switches dark<->light and persists', async ({ page }) => {
|
||||
await setupOwner(page);
|
||||
// 既定は dark
|
||||
expect(
|
||||
await page.evaluate(() => document.documentElement.className)
|
||||
).toContain('dark');
|
||||
|
||||
await page.getByTestId('theme-toggle').click();
|
||||
expect(
|
||||
await page.evaluate(() => document.documentElement.className)
|
||||
).not.toContain('dark');
|
||||
|
||||
// リロード後もライトが維持される(Cookie永続化)
|
||||
await page.reload();
|
||||
expect(
|
||||
await page.evaluate(() => document.documentElement.className)
|
||||
).not.toContain('dark');
|
||||
});
|
||||
|
||||
test('language switch ja -> en updates chrome and persists', async ({
|
||||
page,
|
||||
}) => {
|
||||
const projectId = await setupOwner(page);
|
||||
await page.goto(`/projects/${projectId}`);
|
||||
// E2E既定は ja → ヘッダーに ダッシュボード
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'ダッシュボード' })
|
||||
).toBeVisible();
|
||||
|
||||
// プロフィールで en に切替
|
||||
await page.goto('/profile');
|
||||
await page.getByTestId('profile-locale-select').selectOption('en');
|
||||
|
||||
// クロムが英語に切替
|
||||
await page.goto(`/projects/${projectId}`);
|
||||
await expect(page.getByRole('link', { name: 'Dashboard' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@ -183,6 +183,7 @@ describe('Migrator', () => {
|
||||
'001_initial.sql',
|
||||
'002_attachments.sql',
|
||||
'003_todo_tags.sql',
|
||||
'004_user_prefs.sql',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
68
tests/unit/lib/i18n/server.test.ts
Normal file
68
tests/unit/lib/i18n/server.test.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('next/headers', () => ({
|
||||
cookies: vi.fn(),
|
||||
}));
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
import { getLocale, getTheme, translate } from '@/lib/i18n/server';
|
||||
|
||||
const mockedCookies = vi.mocked(cookies);
|
||||
|
||||
function mockCookie(value: string | undefined) {
|
||||
mockedCookies.mockResolvedValue({
|
||||
get: () => (value === undefined ? undefined : { value }),
|
||||
} as never);
|
||||
}
|
||||
|
||||
function mockCookieByName(map: Record<string, string | undefined>) {
|
||||
mockedCookies.mockResolvedValue({
|
||||
get: (name: string) =>
|
||||
map[name] === undefined ? undefined : { value: map[name] },
|
||||
} as never);
|
||||
}
|
||||
|
||||
describe('i18n/server', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('getLocale', () => {
|
||||
it('defaults to en when the cookie is absent', async () => {
|
||||
mockCookie(undefined);
|
||||
expect(await getLocale()).toBe('en');
|
||||
});
|
||||
it('returns ja when the cookie is ja', async () => {
|
||||
mockCookieByName({ locale: 'ja' });
|
||||
expect(await getLocale()).toBe('ja');
|
||||
});
|
||||
it('falls back to en for an unknown value', async () => {
|
||||
mockCookieByName({ locale: 'fr' });
|
||||
expect(await getLocale()).toBe('en');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTheme', () => {
|
||||
it('defaults to dark when the cookie is absent', async () => {
|
||||
mockCookie(undefined);
|
||||
expect(await getTheme()).toBe('dark');
|
||||
});
|
||||
it('returns light when the cookie is light', async () => {
|
||||
mockCookieByName({ theme: 'light' });
|
||||
expect(await getTheme()).toBe('light');
|
||||
});
|
||||
it('falls back to dark for an unknown value', async () => {
|
||||
mockCookieByName({ theme: 'pink' });
|
||||
expect(await getTheme()).toBe('dark');
|
||||
});
|
||||
});
|
||||
|
||||
describe('translate', () => {
|
||||
it('returns the localized string for each locale', () => {
|
||||
expect(translate('nav.board', 'en')).toBe('Board');
|
||||
expect(translate('nav.board', 'ja')).toBe('掲示板');
|
||||
});
|
||||
it('falls back to the english string then the key for unknown keys', () => {
|
||||
// @ts-expect-error -- nonexistent key on purpose
|
||||
expect(translate('nonexistent.key', 'en')).toBe('nonexistent.key');
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -123,5 +123,29 @@ describe('UserRepository', () => {
|
||||
// UPDATE affects 0 rows; findById returns null
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('defaults theme to dark and locale to en on create', () => {
|
||||
const created = repo.create({
|
||||
name: 'Pref',
|
||||
email: 'p@example.com',
|
||||
passwordHash: 'h',
|
||||
});
|
||||
expect(created.theme).toBe('dark');
|
||||
expect(created.locale).toBe('en');
|
||||
});
|
||||
|
||||
it('updates theme and locale', () => {
|
||||
const created = repo.create({
|
||||
name: 'Pref',
|
||||
email: 'p@example.com',
|
||||
passwordHash: 'h',
|
||||
});
|
||||
const updated = repo.update(created.id, {
|
||||
theme: 'light',
|
||||
locale: 'ja',
|
||||
});
|
||||
expect(updated?.theme).toBe('light');
|
||||
expect(updated?.locale).toBe('ja');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -3,7 +3,12 @@ import { createMigratedTestDb } from '@/tests/helpers/db';
|
||||
import type { SqliteDatabase } from '@/lib/db/sqlite';
|
||||
import { UserRepository } from '@/repositories/UserRepository';
|
||||
import { AuthService } from '@/services/AuthService';
|
||||
import { ConflictError, UnauthorizedError, NotFoundError } from '@/lib/errors';
|
||||
import {
|
||||
ConflictError,
|
||||
UnauthorizedError,
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
} from '@/lib/errors';
|
||||
import { verifySessionToken } from '@/lib/auth/session';
|
||||
import bcrypt from 'bcrypt';
|
||||
|
||||
@ -162,6 +167,38 @@ describe('AuthService', () => {
|
||||
NotFoundError
|
||||
);
|
||||
});
|
||||
|
||||
it('updates theme and locale', () => {
|
||||
const created = authService.register({
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
const updated = authService.updateProfile(created.id, {
|
||||
theme: 'light',
|
||||
locale: 'ja',
|
||||
});
|
||||
expect(updated.theme).toBe('light');
|
||||
expect(updated.locale).toBe('ja');
|
||||
});
|
||||
|
||||
it('rejects an invalid theme or locale', () => {
|
||||
const created = authService.register({
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
expect(() =>
|
||||
authService.updateProfile(created.id, {
|
||||
theme: 'pink' as never,
|
||||
})
|
||||
).toThrow(ValidationError);
|
||||
expect(() =>
|
||||
authService.updateProfile(created.id, {
|
||||
locale: 'fr' as never,
|
||||
})
|
||||
).toThrow(ValidationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createWithRole', () => {
|
||||
|
||||
Reference in New Issue
Block a user