Files
opengroupware/components/board/ThreadForm.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

112 lines
3.5 KiB
TypeScript
Raw 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 { useRef, useState, type FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import { AttachmentPicker } from '@/components/files/AttachmentPicker';
import type { AttachmentPickerHandle } from '@/components/files/AttachmentPicker';
const CATEGORIES = [
{ value: '', label: 'なし' },
{ value: 'notice', label: 'notice' },
{ value: 'spec', label: 'spec' },
{ value: 'minutes', label: 'minutes' },
{ value: 'question', label: 'question' },
{ value: 'decision', label: 'decision' },
{ value: 'trouble', label: 'trouble' },
{ value: 'memo', label: 'memo' },
] as const;
export function ThreadForm({ projectId }: { projectId: number }) {
const router = useRouter();
const pickerRef = useRef<AttachmentPickerHandle>(null);
const [title, setTitle] = useState('');
const [bodyMd, setBodyMd] = useState('');
const [category, setCategory] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [pickerLoading, setPickerLoading] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setLoading(true);
setError(null);
const fileIds = pickerRef.current?.getFileIds() ?? [];
const res = await fetch(`/api/projects/${projectId}/board/threads`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
bodyMd,
category: category || undefined,
fileIds,
}),
});
setLoading(false);
if (res.ok) {
const data = (await res.json()) as { thread: { id: number } };
pickerRef.current?.clear();
router.push(`/projects/${projectId}/board/${data.thread.id}`);
router.refresh();
} else {
const b = (await res.json().catch(() => null)) as {
error?: { message?: string };
} | null;
setError(b?.error?.message ?? '作成に失敗しました');
}
}
return (
<form
onSubmit={onSubmit}
className="space-y-3 rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm"
>
<div className="flex gap-2">
<input
type="text"
placeholder="タイトル"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="flex-1 rounded border px-3 py-2"
required
maxLength={200}
/>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="rounded border px-2 py-2"
>
{CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</select>
</div>
<textarea
placeholder="本文Markdown"
value={bodyMd}
onChange={(e) => setBodyMd(e.target.value)}
className="min-h-[120px] w-full rounded border px-3 py-2"
required
/>
<AttachmentPicker
ref={pickerRef}
projectId={projectId}
onLoadingChange={setPickerLoading}
/>
{error && (
<p className="text-sm text-red-600" role="alert">
{error}
</p>
)}
<button
type="submit"
disabled={loading || pickerLoading}
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? '作成中...' : 'スレッド作成'}
</button>
</form>
);
}