Compare commits
43 Commits
feature/m2
...
0756bba2e5
| Author | SHA1 | Date | |
|---|---|---|---|
| 0756bba2e5 | |||
| 03cf27a148 | |||
| 2588c0247b | |||
| a845f51a35 | |||
| 9d7a23ea94 | |||
| 921cdc457d | |||
| 6a134a29bd | |||
| 91dd05ba7b | |||
| b2b3fb00b5 | |||
| 25d800a529 | |||
| c747978f3d | |||
| 41d65f58bb | |||
| 9fce9e9215 | |||
| f65f3c435c | |||
| f9720850b2 | |||
| 2bc883cb6f | |||
| 0026edd22b | |||
| 384e61386a | |||
| 755c242d2b | |||
| a632574fb0 | |||
| 29e4bc6650 | |||
| 09e1e5e22a | |||
| 31d8ad1325 | |||
| d9d2ba9819 | |||
| 60fef5c0c9 | |||
| 1425773cd4 | |||
| 385b251cc7 | |||
| d2a4d56543 | |||
| ddad5ae78c | |||
| 83a02265f5 | |||
| e45aea8e27 | |||
| fb61a7f592 | |||
| 2017585f84 | |||
| ac598a50f2 | |||
| 3e02feb221 | |||
| 95722e99f1 | |||
| 3dc0318011 | |||
| abef2c58d9 | |||
| 9eb391ea8d | |||
| 8a67523c0e | |||
| 21b9f03e9d | |||
| 40ff4fb909 | |||
| d7c4cd5e58 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -47,6 +47,9 @@ Thumbs.db
|
|||||||
coverage/
|
coverage/
|
||||||
.nyc_output/
|
.nyc_output/
|
||||||
|
|
||||||
|
# Steering files (task management - temporary)
|
||||||
|
.steering/*
|
||||||
|
!.steering/.gitkeep
|
||||||
|
|
||||||
# Lock files (keep package-lock.json for consistency)
|
# Lock files (keep package-lock.json for consistency)
|
||||||
# yarn.lock
|
# yarn.lock
|
||||||
|
|||||||
@ -1,189 +0,0 @@
|
|||||||
# Design Document
|
|
||||||
|
|
||||||
## Architecture Overview
|
|
||||||
|
|
||||||
Layered architecture (UI → Service → Repository → Data) per `docs/architecture.md`. Next.js 15 App Router with Node.js Runtime only (no Edge Runtime). SQLite via better-sqlite3 accessed exclusively through a custom SQL wrapper. No Prisma.
|
|
||||||
|
|
||||||
```
|
|
||||||
app/ (UI: Route Handlers / Server Components / SSE)
|
|
||||||
↓
|
|
||||||
services/ (business logic, permission checks, transactions)
|
|
||||||
↓
|
|
||||||
repositories/ (SQL, parameter binding, logical-delete filter)
|
|
||||||
↓
|
|
||||||
lib/db/ (SQLite connection, SQL wrapper, Migration)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Component Design
|
|
||||||
|
|
||||||
### 1. SQL Wrapper: `lib/db/sqlite.ts`
|
|
||||||
|
|
||||||
**Responsibilities**:
|
|
||||||
- SQLite connection management (singleton getDb(), WAL, foreign_keys ON)
|
|
||||||
- query<T> (multi-row), get<T> (single-row), execute (insert/update/delete), transaction<T>, close
|
|
||||||
- Error handling
|
|
||||||
|
|
||||||
**Implementation highlights**:
|
|
||||||
- dbPath = process.env.SQLITE_PATH ?? "./data/app.db"
|
|
||||||
- SqlParams = Record<string, unknown> | unknown[]
|
|
||||||
- All repositories use this wrapper; direct better-sqlite3 access forbidden
|
|
||||||
|
|
||||||
### 2. Migrator: `lib/db/migrator.ts`
|
|
||||||
|
|
||||||
**Responsibilities**:
|
|
||||||
- Create schema_migrations table
|
|
||||||
- Read .sql files from migrations dir in filename order
|
|
||||||
- Skip already-applied files (tracked in schema_migrations)
|
|
||||||
- Execute each file in a transaction; rollback on failure
|
|
||||||
|
|
||||||
### 3. Initial Schema: `lib/db/migrations/001_initial.sql`
|
|
||||||
|
|
||||||
16 tables: users, projects, project_members, board_threads, board_comments, chat_messages, todo_columns, todo_items, file_assets, project_notes, milestones, calendar_events, meetings, meeting_members, notifications, activity_logs + indexes.
|
|
||||||
|
|
||||||
### 4. Entity Types: `lib/types/`
|
|
||||||
|
|
||||||
PascalCase type files for each entity + union types (UserRole, UserStatus, ProjectStatus, ProjectMemberRole, etc.).
|
|
||||||
|
|
||||||
### 5. UserRepository
|
|
||||||
|
|
||||||
findById, findByEmail, create (with passwordHash), update (name/avatarUrl/role/status).
|
|
||||||
|
|
||||||
### 6. AuthService
|
|
||||||
|
|
||||||
register (hash password, create user), login (verify hash, issue session), logout, getCurrentUser, updateProfile. Uses bcrypt.
|
|
||||||
|
|
||||||
### 7. Session: `lib/auth/session.ts`, `lib/auth/getCurrentUser.ts`
|
|
||||||
|
|
||||||
Cookie-based session. getCurrentUser resolves the user from the request cookie.
|
|
||||||
|
|
||||||
### 8. ProjectRepository / ProjectMemberRepository
|
|
||||||
|
|
||||||
Project: findById, findByOwner, findByUser (via join), create, update, delete.
|
|
||||||
ProjectMember: findByProject, findByUser, add, remove, isMember, getRole.
|
|
||||||
|
|
||||||
### 9. ProjectService
|
|
||||||
|
|
||||||
createProject (owner becomes admin member), updateProject, addMember (permission check + notification hook later), removeMember, archiveProject, getDashboard skeleton.
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
|
|
||||||
### Login
|
|
||||||
1. POST /api/auth/login → AuthService.login(email, password)
|
|
||||||
2. AuthService → UserRepository.findByEmail → verify bcrypt hash
|
|
||||||
3. Issue session cookie → return user
|
|
||||||
|
|
||||||
### Project creation
|
|
||||||
1. POST /api/projects → ProjectService.createProject(actorId, input)
|
|
||||||
2. ProjectService → permission (any authenticated user) → ProjectRepository.create
|
|
||||||
3. ProjectMemberRepository.add(projectId, ownerId, 'admin')
|
|
||||||
4. Return project
|
|
||||||
|
|
||||||
## Error Handling Strategy
|
|
||||||
|
|
||||||
### Custom Error Classes
|
|
||||||
- ValidationError (400) - field, value
|
|
||||||
- ForbiddenError (403)
|
|
||||||
- NotFoundError (404) - resource, id
|
|
||||||
- (409 for unique constraint - handled via SQLite error code)
|
|
||||||
|
|
||||||
### Error Handling Patterns
|
|
||||||
Route Handlers catch errors and map to HTTP status. Expected errors use custom classes; unexpected errors propagate + log.
|
|
||||||
|
|
||||||
## Test Strategy
|
|
||||||
|
|
||||||
### Unit Tests (Vitest)
|
|
||||||
- lib/db/sqlite.test.ts (query/get/execute/transaction, WAL, foreign keys)
|
|
||||||
- lib/db/migrator.test.ts (order, skip applied, rollback)
|
|
||||||
- repositories/UserRepository.test.ts
|
|
||||||
- services/AuthService.test.ts
|
|
||||||
- repositories/ProjectRepository.test.ts
|
|
||||||
- repositories/ProjectMemberRepository.test.ts
|
|
||||||
- services/ProjectService.test.ts
|
|
||||||
|
|
||||||
Tests use a temp SQLite file (in-memory or tmpdir) to avoid touching real data/.
|
|
||||||
|
|
||||||
### Integration Tests (Vitest)
|
|
||||||
- project-member-permission.test.ts (non-member cannot access project data)
|
|
||||||
|
|
||||||
### E2E Tests (Playwright)
|
|
||||||
- auth.spec.ts (login, logout, protected screen)
|
|
||||||
- project-management.spec.ts (create, edit, member add/remove, archive)
|
|
||||||
|
|
||||||
## Dependent Libraries
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"dependencies": {
|
|
||||||
"next": "15",
|
|
||||||
"react": "19",
|
|
||||||
"react-dom": "19",
|
|
||||||
"better-sqlite3": "^11",
|
|
||||||
"bcrypt": "^5",
|
|
||||||
"react-markdown": "^9",
|
|
||||||
"remark-gfm": "^4",
|
|
||||||
"rehype-sanitize": "^6"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"tailwindcss": "^3",
|
|
||||||
"@types/better-sqlite3": "^7",
|
|
||||||
"@types/bcrypt": "^5",
|
|
||||||
"@playwright/test": "^1",
|
|
||||||
"tsx": "^4"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Directory Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
app/
|
|
||||||
api/auth/{register,login,logout,me}/route.ts
|
|
||||||
api/users/me/route.ts
|
|
||||||
api/projects/route.ts
|
|
||||||
api/projects/[projectId]/route.ts
|
|
||||||
api/projects/[projectId]/members/route.ts
|
|
||||||
api/projects/[projectId]/members/[userId]/route.ts
|
|
||||||
api/admin/migrations/route.ts
|
|
||||||
login/page.tsx
|
|
||||||
profile/page.tsx
|
|
||||||
dashboard/page.tsx
|
|
||||||
projects/[projectId]/{page,members,settings}.tsx
|
|
||||||
layout.tsx, globals.css
|
|
||||||
lib/
|
|
||||||
db/{sqlite.ts, migrator.ts, run-migrations.ts, migrations/001_initial.sql}
|
|
||||||
auth/{session.ts, getCurrentUser.ts}
|
|
||||||
types/*.ts
|
|
||||||
validators/{userValidator.ts, projectValidator.ts}
|
|
||||||
repositories/{UserRepository,ProjectRepository,ProjectMemberRepository}.ts
|
|
||||||
services/{AuthService,ProjectService}.ts
|
|
||||||
components/layout/{Header,Sidebar,ProjectNav}.tsx
|
|
||||||
tests/unit/..., tests/integration/..., tests/e2e/...
|
|
||||||
```
|
|
||||||
|
|
||||||
## Implementation Order
|
|
||||||
|
|
||||||
1. M1 (branch feature/m1-setup): Next.js setup, deps, config, dir structure, remove boilerplate → merge to main
|
|
||||||
2. M2 (branch feature/m2-db-foundation): SQL wrapper, migrator, schema, types, migration API, unit tests → merge to main
|
|
||||||
3. M3 (branch feature/m3-auth-user): UserRepository, AuthService, session, validators, auth API, login/profile pages, unit + e2e → merge to main
|
|
||||||
4. M4 (branch feature/m4-project-member): Project/Member repos, ProjectService, project API, dashboard/members/settings pages, unit + integration + e2e → merge to main
|
|
||||||
|
|
||||||
## Security Considerations
|
|
||||||
|
|
||||||
- Passwords hashed with bcrypt (never plaintext)
|
|
||||||
- All SQL parameter-bound (no string concatenation)
|
|
||||||
- Auth required on all protected routes (except /api/auth/register, /api/auth/login)
|
|
||||||
- Project access requires isMember check
|
|
||||||
- Admin features require role='system_admin'
|
|
||||||
- .env not committed; secrets via environment
|
|
||||||
|
|
||||||
## Performance Considerations
|
|
||||||
|
|
||||||
- SQLite WAL mode for read/write concurrency
|
|
||||||
- Pagination on list endpoints
|
|
||||||
- Singleton DB connection
|
|
||||||
|
|
||||||
## Future Extensibility
|
|
||||||
|
|
||||||
- Repository pattern allows adding tables without changing wrapper
|
|
||||||
- SseEvent type extensible (M8)
|
|
||||||
- NotificationService/ActivityLogService hooks prepared for M5 (ProjectService.addMember will call notification in M5)
|
|
||||||
@ -1,102 +0,0 @@
|
|||||||
# Requirements
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Implement milestones M1-M4 of the Simple Groupware project: project foundation setup (Next.js 15), DB foundation (SQL wrapper + Migration), authentication & user management, and project & member management. Each milestone is developed on a separate branch and merged to main. Unit tests and E2E tests are mandatory.
|
|
||||||
|
|
||||||
## Background
|
|
||||||
|
|
||||||
The repository is currently a spec-driven-development boilerplate (vitest/eslint/prettier/husky configured, but no Next.js, no react, no SQLite). It must be transformed into the foundation of the Simple Groupware: a Next.js 15 + TypeScript + SQLite(better-sqlite3) application following the layered architecture (UI → Service → Repository → Data) defined in `docs/architecture.md`. M1-M4 establish the base that all subsequent milestones (M5+) depend on.
|
|
||||||
|
|
||||||
## Features to Implement
|
|
||||||
|
|
||||||
### 1. M1: Project Foundation Setup
|
|
||||||
- Next.js 15 (App Router, TypeScript) project configuration
|
|
||||||
- Tailwind CSS setup
|
|
||||||
- tsconfig with `@/*` path alias, strict mode
|
|
||||||
- next.config, .env.example
|
|
||||||
- Directory structure: app/, lib/, repositories/, services/, components/, tests/, data/, backups/
|
|
||||||
- ESLint (with Next.js plugin), Prettier, Husky, lint-staged, Vitest, Playwright setup
|
|
||||||
- CI config (GitHub Actions)
|
|
||||||
- package.json scripts: lint, format, typecheck, test, test:e2e, migrate, dev, build
|
|
||||||
- Dependencies: next, react, react-dom, better-sqlite3, bcrypt, react-markdown, remark-gfm, rehype-sanitize, tailwindcss, @types/better-sqlite3
|
|
||||||
- Remove boilerplate src/example.ts and src/example.test.ts
|
|
||||||
|
|
||||||
### 2. M2: DB Foundation (SQL Wrapper + Migration)
|
|
||||||
- `lib/db/sqlite.ts`: SqliteDatabase class (query/get/execute/transaction/close) + getDb() singleton (WAL, foreign_keys ON)
|
|
||||||
- `lib/db/migrator.ts`: Migrator class (filename-order execution, skip applied, 1-file-1-transaction, rollback on failure)
|
|
||||||
- `lib/db/migrations/001_initial.sql`: all 16 tables + indexes
|
|
||||||
- `lib/types/`: all Entity type definitions + enums
|
|
||||||
- `lib/db/run-migrations.ts`: migration runner script
|
|
||||||
- API: `GET /api/admin/migrations` (admin-only migration status)
|
|
||||||
- Unit tests: sqlite.test.ts, migrator.test.ts
|
|
||||||
|
|
||||||
### 3. M3: Authentication & User Management
|
|
||||||
- UserRepository (findById/findByEmail/create/update)
|
|
||||||
- AuthService (register/login/logout/getCurrentUser/updateProfile) with bcrypt password hashing
|
|
||||||
- Role management (system_admin/project_admin/member/guest), account enable/disable
|
|
||||||
- lib/auth/ (session.ts, getCurrentUser.ts)
|
|
||||||
- lib/validators/userValidator.ts
|
|
||||||
- API: register, login, logout, me, users/me
|
|
||||||
- Login page, profile page, root layout
|
|
||||||
- Auth middleware (redirect unauthenticated to login)
|
|
||||||
- Unit tests: UserRepository, AuthService; E2E test: auth.spec.ts
|
|
||||||
|
|
||||||
### 4. M4: Project & Member Management
|
|
||||||
- ProjectRepository, ProjectMemberRepository
|
|
||||||
- ProjectService (createProject/updateProject/addMember/removeMember/archiveProject/getDashboard) with permission checks
|
|
||||||
- projectValidator.ts
|
|
||||||
- API: projects CRUD, members CRUD
|
|
||||||
- Dashboard (project list), project overview, members page, settings page
|
|
||||||
- Layout components (Header, Sidebar, ProjectNav)
|
|
||||||
- Unit tests: ProjectRepository, ProjectMemberRepository, ProjectService; Integration test: project-member-permission; E2E test: project-management.spec.ts
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
### M1
|
|
||||||
- [ ] `npm run dev` starts the Next.js dev server
|
|
||||||
- [ ] `npm run lint`, `npm run typecheck`, `npm run build` succeed
|
|
||||||
- [ ] Directory structure matches repository-structure.md
|
|
||||||
|
|
||||||
### M2
|
|
||||||
- [ ] `npm run migrate` creates the initial schema
|
|
||||||
- [ ] Migrations run in filename order and skip applied ones
|
|
||||||
- [ ] Failed migration rolls back
|
|
||||||
- [ ] Unit tests pass
|
|
||||||
|
|
||||||
### M3
|
|
||||||
- [ ] User registration, login, logout work
|
|
||||||
- [ ] Password is hashed with bcrypt
|
|
||||||
- [ ] Profile & avatar editable
|
|
||||||
- [ ] Inactive accounts cannot log in
|
|
||||||
- [ ] Unauthenticated access to protected screens is blocked
|
|
||||||
- [ ] Unit & E2E tests pass
|
|
||||||
|
|
||||||
### M4
|
|
||||||
- [ ] Project create/edit/delete/archive work
|
|
||||||
- [ ] Member add/remove/role work
|
|
||||||
- [ ] Non-members get 403
|
|
||||||
- [ ] Unit, Integration & E2E tests pass
|
|
||||||
|
|
||||||
## Success Metrics
|
|
||||||
|
|
||||||
- All Unit tests pass (`npm test`)
|
|
||||||
- All E2E tests pass (`npm run test:e2e`)
|
|
||||||
- Lint and typecheck clean
|
|
||||||
- Each milestone merged to main on its own branch
|
|
||||||
|
|
||||||
## Out of Scope
|
|
||||||
|
|
||||||
The following will not be implemented in this phase:
|
|
||||||
- M5-M15 (notifications, board, chat, SSE, todo, files, calendar, milestones, meetings, search, dashboard completion, backup)
|
|
||||||
- E2E tests for features beyond M3/M4 scope (board, chat, etc.)
|
|
||||||
- Full project dashboard content (only skeleton in M4; full content in M13)
|
|
||||||
|
|
||||||
## Reference Documents
|
|
||||||
|
|
||||||
- `docs/product-requirements.md` - Product Requirements Document
|
|
||||||
- `docs/functional-design.md` - Functional design document
|
|
||||||
- `docs/architecture.md` - Architecture design document
|
|
||||||
- `docs/repository-structure.md` - Repository structure document
|
|
||||||
- `docs/development-guidelines.md` - Development guidelines
|
|
||||||
- `docs/milestones.md` - Milestone & task definitions
|
|
||||||
@ -1,150 +0,0 @@
|
|||||||
# Task List
|
|
||||||
|
|
||||||
## 🚨 Principle of Fully Completing Tasks
|
|
||||||
|
|
||||||
**Keep working until all tasks in this file are complete**
|
|
||||||
|
|
||||||
### Mandatory Rules
|
|
||||||
- **Make every task `[x]`**
|
|
||||||
- "Planned as a separate task due to time constraints" is forbidden
|
|
||||||
- "Postponed because the implementation is too complex" is forbidden
|
|
||||||
- Do not finish work while leaving incomplete tasks (`[ ]`)
|
|
||||||
|
|
||||||
### The Only Case Where Skipping a Task Is Permitted
|
|
||||||
Skipping is possible only when one of the following technical reasons applies:
|
|
||||||
- A change in the implementation approach made the feature itself unnecessary
|
|
||||||
- An architecture change replaced it with a different implementation method
|
|
||||||
- A change in dependencies made the task impossible to execute
|
|
||||||
|
|
||||||
When skipping, always state the reason clearly:
|
|
||||||
```markdown
|
|
||||||
- [x] ~~task name~~ (unnecessary due to a change in approach: specific technical reason)
|
|
||||||
```
|
|
||||||
|
|
||||||
### If a Task Is Too Large
|
|
||||||
- Split the task into smaller subtasks
|
|
||||||
- Add the split subtasks to this file
|
|
||||||
- Complete the subtasks one by one
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 1: M1 - Project Foundation Setup (branch: feature/m1-setup)
|
|
||||||
|
|
||||||
- [x] Create branch `feature/m1-setup` from main
|
|
||||||
- [x] Update package.json: name, scripts (dev/build/lint/format/typecheck/test/test:e2e/migrate), dependencies (next, react, react-dom, better-sqlite3, bcrypt, react-markdown, remark-gfm, rehype-sanitize), devDependencies (tailwindcss, @types/better-sqlite3, @types/bcrypt, @playwright/test, tsx, eslint-config-next)
|
|
||||||
- [x] Run `npm install` and confirm dependencies install successfully
|
|
||||||
- [x] Configure Next.js: create next.config.mjs (Node.js runtime), update tsconfig.json for Next.js (@/* path alias, jsx preserve, next plugin types), create app/globals.css (Tailwind directives), create tailwind.config.ts, postcss.config.mjs
|
|
||||||
- [x] Create Next.js app structure: app/layout.tsx (root layout), app/page.tsx (home redirect), app/globals.css
|
|
||||||
- [x] Create directory structure: lib/, repositories/, services/, components/, tests/unit, tests/integration, tests/e2e, data/.gitkeep, backups/.gitkeep
|
|
||||||
- [x] Remove boilerplate: delete src/example.ts, src/example.test.ts
|
|
||||||
- [x] Update vitest.config.ts (include tests/**, exclude .next), create playwright.config.ts
|
|
||||||
- [x] Update eslint.config.js (add Next.js plugin/ignores for .next), update .prettierrc if needed
|
|
||||||
- [x] Create .env.example (SQLITE_PATH, SESSION_SECRET)
|
|
||||||
- [x] Create CI config .github/workflows/ci.yml (lint, typecheck, test, build)
|
|
||||||
- [x] Commit M1, merge feature/m1-setup to main, push
|
|
||||||
|
|
||||||
## Phase 2: M2 - DB Foundation (branch: feature/m2-db-foundation)
|
|
||||||
|
|
||||||
- [ ] Create branch `feature/m2-db-foundation` from main
|
|
||||||
- [ ] Implement `lib/db/sqlite.ts`: SqliteDatabase class (query/get/execute/transaction/close) + getDb() singleton (WAL, foreign_keys ON)
|
|
||||||
- [ ] Implement `lib/db/migrator.ts`: Migrator class (schema_migrations table, filename-order, skip applied, 1-file-1-tx, rollback)
|
|
||||||
- [ ] Create `lib/db/migrations/001_initial.sql`: all 16 tables + indexes
|
|
||||||
- [ ] Create `lib/types/`: all Entity types + enums (User, Project, ProjectMember, BoardThread, BoardComment, ChatMessage, TodoColumn, TodoItem, FileAsset, ProjectNote, Milestone, CalendarEvent, Meeting, MeetingMember, Notification, ActivityLog, SchemaMigration + union types)
|
|
||||||
- [ ] Create `lib/db/run-migrations.ts`: migration runner script
|
|
||||||
- [ ] Implement API `GET /api/admin/migrations` (migration status, admin-only)
|
|
||||||
- [ ] Create test helper `tests/helpers/db.ts` (temp SQLite DB factory)
|
|
||||||
- [ ] Write Unit test `tests/unit/lib/db/sqlite.test.ts`
|
|
||||||
- [ ] Write Unit test `tests/unit/lib/db/migrator.test.ts`
|
|
||||||
- [ ] Run `npm run migrate` to verify schema creation
|
|
||||||
- [ ] Commit M2, merge feature/m2-db-foundation to main, push
|
|
||||||
|
|
||||||
## Phase 3: M3 - Auth & User Management (branch: feature/m3-auth-user)
|
|
||||||
|
|
||||||
- [ ] Create branch `feature/m3-auth-user` from main
|
|
||||||
- [ ] Create custom error classes `lib/errors.ts` (ValidationError, ForbiddenError, NotFoundError)
|
|
||||||
- [ ] Implement `lib/auth/session.ts` (session cookie read/write) and `lib/auth/getCurrentUser.ts`
|
|
||||||
- [ ] Implement `lib/validators/userValidator.ts`
|
|
||||||
- [ ] Implement `repositories/UserRepository.ts`
|
|
||||||
- [ ] Implement `services/AuthService.ts` (register/login/logout/getCurrentUser/updateProfile, bcrypt)
|
|
||||||
- [ ] Implement API routes: register, login, logout, me, users/me
|
|
||||||
- [ ] Implement auth middleware/guard (protected routes redirect to login)
|
|
||||||
- [ ] Implement `app/login/page.tsx`, `app/profile/page.tsx`, update `app/layout.tsx`
|
|
||||||
- [ ] Write Unit test `tests/unit/repositories/UserRepository.test.ts`
|
|
||||||
- [ ] Write Unit test `tests/unit/services/AuthService.test.ts`
|
|
||||||
- [ ] Write E2E test `tests/e2e/auth.spec.ts`
|
|
||||||
- [ ] Commit M3, merge feature/m3-auth-user to main, push
|
|
||||||
|
|
||||||
## Phase 4: M4 - Project & Member Management (branch: feature/m4-project-member)
|
|
||||||
|
|
||||||
- [ ] Create branch `feature/m4-project-member` from main
|
|
||||||
- [ ] Implement `lib/validators/projectValidator.ts`
|
|
||||||
- [ ] Implement `repositories/ProjectRepository.ts`
|
|
||||||
- [ ] Implement `repositories/ProjectMemberRepository.ts`
|
|
||||||
- [ ] Implement `services/ProjectService.ts` (createProject/updateProject/addMember/removeMember/archiveProject/getDashboard skeleton, permission checks)
|
|
||||||
- [ ] Implement API routes: projects (list/create), projects/[projectId] (detail/edit/delete), members (list/add), members/[userId] (remove)
|
|
||||||
- [ ] Implement `app/dashboard/page.tsx` (project list skeleton)
|
|
||||||
- [ ] Implement `app/projects/[projectId]/page.tsx` (overview skeleton)
|
|
||||||
- [ ] Implement `app/projects/[projectId]/members/page.tsx`
|
|
||||||
- [ ] Implement `app/projects/[projectId]/settings/page.tsx`
|
|
||||||
- [ ] Implement layout components: components/layout/Header.tsx, Sidebar.tsx, ProjectNav.tsx
|
|
||||||
- [ ] Write Unit test `tests/unit/repositories/ProjectRepository.test.ts`
|
|
||||||
- [ ] Write Unit test `tests/unit/repositories/ProjectMemberRepository.test.ts`
|
|
||||||
- [ ] Write Unit test `tests/unit/services/ProjectService.test.ts`
|
|
||||||
- [ ] Write Integration test `tests/integration/project-member-permission.test.ts`
|
|
||||||
- [ ] Write E2E test `tests/e2e/project-management.spec.ts`
|
|
||||||
- [ ] Commit M4, merge feature/m4-project-member to main, push
|
|
||||||
|
|
||||||
## Phase 5: Quality Check and Fixes
|
|
||||||
|
|
||||||
- [ ] Confirm that all tests pass
|
|
||||||
- [ ] `npm test`
|
|
||||||
- [ ] Confirm that there are no lint errors
|
|
||||||
- [ ] `npm run lint`
|
|
||||||
- [ ] Confirm that there are no type errors
|
|
||||||
- [ ] `npm run typecheck`
|
|
||||||
- [ ] Confirm that the build succeeds
|
|
||||||
- [ ] `npm run build`
|
|
||||||
|
|
||||||
## Phase 6: Documentation Updates
|
|
||||||
|
|
||||||
- [ ] Update README.md (setup instructions, scripts)
|
|
||||||
- [ ] Post-implementation retrospective (record at the bottom of this file)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Post-Implementation Retrospective
|
|
||||||
|
|
||||||
### Implementation Completion Date
|
|
||||||
{YYYY-MM-DD}
|
|
||||||
|
|
||||||
### Differences Between Plan and Actual
|
|
||||||
|
|
||||||
**Points that differed from the plan**:
|
|
||||||
- {Technical changes not anticipated at planning time}
|
|
||||||
- {Changes in the implementation approach and the reasons}
|
|
||||||
|
|
||||||
**Tasks that became newly necessary**:
|
|
||||||
- {Tasks added during implementation}
|
|
||||||
- {Why the addition was necessary}
|
|
||||||
|
|
||||||
**Tasks skipped for technical reasons** (only when applicable):
|
|
||||||
- {Task name}
|
|
||||||
- Reason for skipping: {specific technical reason}
|
|
||||||
- Alternative implementation: {what it was replaced with}
|
|
||||||
|
|
||||||
**⚠️ Note**: Do not list tasks skipped for reasons such as "time constraints" or "difficulty" here. Completing all tasks is the principle.
|
|
||||||
|
|
||||||
### Lessons Learned
|
|
||||||
|
|
||||||
**Technical insights**:
|
|
||||||
- {Technical knowledge gained through implementation}
|
|
||||||
- {New technologies or patterns used}
|
|
||||||
|
|
||||||
**Process improvements**:
|
|
||||||
- {What went well in task management}
|
|
||||||
- {How the steering files were leveraged}
|
|
||||||
|
|
||||||
### Improvement Suggestions for Next Time
|
|
||||||
- {Things to watch out for in the next feature addition}
|
|
||||||
- {More efficient implementation methods}
|
|
||||||
- {Improvements to task planning}
|
|
||||||
596
README.md
596
README.md
@ -1,396 +1,288 @@
|
|||||||
# opencode Spec-Driven Development Boilerplate
|
# OpenGroupware
|
||||||
|
|
||||||
A spec-driven development boilerplate for [opencode](https://opencode.ai), converted from the
|
A simple, self-contained, project-based groupware built with **Next.js 15**, **TypeScript**, and
|
||||||
[Claude Code boilerplate](https://git.yasue.org/ken/claudecode-boilerplate) that accompanies the book
|
**SQLite**. It runs entirely on Node.js (no external database or storage) and provides boards,
|
||||||
*"Practical Claude Code Introduction."*
|
Markdown notes, realtime chat (SSE), a Kanban ToDo board, file sharing, a calendar with milestones,
|
||||||
|
meetings with schedule-conflict detection, search, dashboards, and admin backups.
|
||||||
|
|
||||||
It gives opencode a structured workflow for turning ideas into production code through persistent design
|
The codebase follows a strict **layered architecture**:
|
||||||
documents, per-task steering files, AI skills, subagents, and slash commands.
|
|
||||||
|
```
|
||||||
|
UI (app/) → Service (services/) → Repository (repositories/) → Data (lib/db/)
|
||||||
|
```
|
||||||
|
|
||||||
|
Dependencies flow one direction only; SQL is always parameter-bound and accessed through the
|
||||||
|
`SqliteDatabase` wrapper (never `better-sqlite3` directly in repositories).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
- [What Is Spec-Driven Development?](#what-is-spec-driven-development)
|
|
||||||
- [Conversion Summary (Claude Code → opencode)](#conversion-summary-claude-code--opencode)
|
|
||||||
- [Prerequisites](#prerequisites)
|
- [Prerequisites](#prerequisites)
|
||||||
- [Quick Start](#quick-start)
|
- [How to Start](#how-to-start)
|
||||||
- [Directory Structure](#directory-structure)
|
- [How to Test](#how-to-test)
|
||||||
- [Configuration Overview](#configuration-overview)
|
- [How to Develop](#how-to-develop)
|
||||||
- [Usage Workflow](#usage-workflow)
|
- [Environment Variables](#environment-variables)
|
||||||
- [Commands Reference](#commands-reference)
|
- [Project Structure](#project-structure)
|
||||||
- [Skills Reference](#skills-reference)
|
- [npm Scripts Reference](#npm-scripts-reference)
|
||||||
- [Agents Reference](#agents-reference)
|
|
||||||
- [Customizing the Boilerplate](#customizing-the-boilerplate)
|
|
||||||
- [Troubleshooting](#troubleshooting)
|
- [Troubleshooting](#troubleshooting)
|
||||||
- [License](#license)
|
- [License](#license)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What Is Spec-Driven Development?
|
|
||||||
|
|
||||||
Spec-driven development separates **"what to build"** from **"how to build it"**:
|
|
||||||
|
|
||||||
1. **Persistent documents** (`docs/`) — the north-star design (PRD, functional design, architecture, etc.)
|
|
||||||
2. **Steering files** (`.steering/`) — per-task plans created fresh for each piece of work
|
|
||||||
3. **Implementation** — the agent follows `tasklist.md`, updating progress as it goes
|
|
||||||
4. **Verification** — tests, lint, typecheck, and a retrospective
|
|
||||||
|
|
||||||
opencode loads the right skill automatically based on what you're doing, so you don't have to think about
|
|
||||||
the process — just describe what you want.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Conversion Summary (Claude Code → opencode)
|
|
||||||
|
|
||||||
| Claude Code | opencode | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| `CLAUDE.md` | `AGENTS.md` | Referenced via `instructions` in `opencode.json` |
|
|
||||||
| `.claude/settings.json` | `opencode.json` | Permissions converted to opencode's `permission` schema |
|
|
||||||
| `.claude/agents/*.md` | `.opencode/agent/*.md` | Added `mode: subagent`; `model: sonnet` removed (inherits default) |
|
|
||||||
| `.claude/commands/*.md` | `.opencode/command/*.md` | Tool-call syntax (`Bash()`, `Grep()`, `Skill()`) converted to natural instructions; `$ARGUMENTS` added |
|
|
||||||
| `.claude/skills/*/SKILL.md` | `.opencode/skills/*/SKILL.md` | `allowed-tools` frontmatter removed (not an opencode field) |
|
|
||||||
| `Skill('steering')` calls | Auto-loaded by description | opencode surfaces skills via `description` matching |
|
|
||||||
| `TodoWrite` / `Edit` tool refs | `todowrite` / `edit` | Lowercased to match opencode tool names |
|
|
||||||
| Claude Code devcontainer feature | `curl … opencode.ai/install` | Replaced Anthropic feature with opencode install script |
|
|
||||||
|
|
||||||
All skill templates and guides were preserved verbatim except for `.claude/` path references and
|
|
||||||
"Claude Code" mentions, which were updated to `.opencode/` and "opencode" respectively.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- **Node.js** v18+ (v24.11.0 recommended)
|
- **Node.js** v24.11.0 (LTS; v18+ works) — the dev container pins this version
|
||||||
- **npm**
|
- **npm** 11.x (bundled with Node.js)
|
||||||
- **opencode** — install with:
|
- **Playwright browsers** for E2E tests (see [How to Test](#how-to-test))
|
||||||
```bash
|
|
||||||
curl -fsSL https://opencode.ai/install | bash
|
A Dev Container configuration (`.devcontainer/`) is included for VS Code.
|
||||||
```
|
|
||||||
See <https://opencode.ai/docs> for alternatives (npm, Homebrew, etc.)
|
|
||||||
- **Docker** (only if using the Dev Container)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start
|
## How to Start
|
||||||
|
|
||||||
### Option A — Dev Container (recommended)
|
|
||||||
|
|
||||||
1. Open this folder in **VS Code**.
|
|
||||||
2. When prompted, click **"Reopen in Container"** (or run the command
|
|
||||||
*Dev Containers: Reopen in Container*).
|
|
||||||
3. The container automatically:
|
|
||||||
- Installs Node.js LTS
|
|
||||||
- Runs `npm install`
|
|
||||||
- Installs opencode
|
|
||||||
4. Open a terminal and start opencode:
|
|
||||||
```bash
|
|
||||||
opencode
|
|
||||||
```
|
|
||||||
|
|
||||||
### Option B — Manual setup
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Install dependencies
|
# 1. Install dependencies
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# 2. Set up Git hooks
|
# 2. Create your local environment file
|
||||||
npm run prepare
|
cp .env.example .env
|
||||||
|
# Edit .env if you want to change SQLITE_PATH / SESSION_SECRET / UPLOADS_PATH.
|
||||||
|
# SESSION_SECRET is required — the app will not start without it.
|
||||||
|
|
||||||
# 3. Start opencode
|
# 3. Initialize the database (runs all SQL migrations)
|
||||||
opencode
|
npm run migrate
|
||||||
|
|
||||||
|
# 4. Start the development server
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The app is then available at **http://localhost:3000**.
|
||||||
|
|
||||||
|
- The first user you register becomes a regular `member`. To use admin features
|
||||||
|
(backups, migration status), promote a user to `system_admin` in the DB, or seed one:
|
||||||
|
```bash
|
||||||
|
npx tsx -e "import('./lib/db/sqlite.ts').then(async m => { \
|
||||||
|
const { UserRepository } = await import('./repositories/UserRepository.ts'); \
|
||||||
|
const bcrypt = (await import('bcrypt')).default; \
|
||||||
|
const db = new m.SqliteDatabase(process.env.SQLITE_PATH ?? './data/app.db'); \
|
||||||
|
new UserRepository(db).create({ name:'Admin', email:'admin@example.com', \
|
||||||
|
passwordHash: bcrypt.hashSync('admin123', 10), role:'system_admin' }); db.close(); })"
|
||||||
|
```
|
||||||
|
- The SQLite database lives at `./data/app.db` (and uploads under `./data/uploads/`). Both are
|
||||||
|
git-ignored. Deleting `./data/` and re-running `npm run migrate` gives a clean slate.
|
||||||
|
|
||||||
|
### Production build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm run start # serves the optimized build on http://localhost:3000
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Directory Structure
|
## How to Test
|
||||||
|
|
||||||
|
There are three test layers: **Unit/Integration** (Vitest) and **E2E** (Playwright).
|
||||||
|
|
||||||
|
### Unit & integration tests (Vitest)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test # run all unit/integration tests once
|
||||||
|
npm run test:watch # watch mode
|
||||||
|
npm run test:coverage # run with coverage (threshold: 80% for repositories/** + services/**)
|
||||||
|
```
|
||||||
|
|
||||||
|
These use a real temporary SQLite database per test (see `tests/helpers/db.ts`) and cover every
|
||||||
|
Repository, Service, and the key algorithms (schedule-conflict, milestone progress, session tokens,
|
||||||
|
notification targeting).
|
||||||
|
|
||||||
|
### End-to-end tests (Playwright)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:e2e # run the full E2E suite (headless)
|
||||||
|
npm run test:e2e:ui # interactive UI mode — test tree + per-step screenshots/traces
|
||||||
|
```
|
||||||
|
|
||||||
|
Before running E2E:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install the Chromium browser once (per machine)
|
||||||
|
npx playwright install chromium
|
||||||
|
```
|
||||||
|
|
||||||
|
How E2E works:
|
||||||
|
- Playwright's `webServer` runs `npm run migrate && npm run dev` automatically, so the DB is
|
||||||
|
initialized and the dev server is started for you.
|
||||||
|
- A `globalSetup` (`tests/e2e/globalSetup.ts`) seeds an `admin@example.com` / `admin123`
|
||||||
|
(`system_admin`) account used by the admin/backup tests.
|
||||||
|
- The suite runs with `workers: 1` for deterministic behavior (the realtime SSE test needs low
|
||||||
|
contention against the single dev server).
|
||||||
|
- E2E specs live in `tests/e2e/*.spec.ts` (auth, project-management, board, notes, chat-sse,
|
||||||
|
todo-kanban, file-sharing, calendar, meetings, search/dashboard, notifications, activity-log,
|
||||||
|
backup).
|
||||||
|
|
||||||
|
### Lint, type-check, build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run lint
|
||||||
|
npm run typecheck
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Run all three green before committing. A pre-commit hook (Husky + lint-staged) auto-fixes and
|
||||||
|
formats staged files.
|
||||||
|
|
||||||
|
### Recommended full check before opening a PR
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run lint && npm run typecheck && npm test && npm run build && npm run test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to Develop
|
||||||
|
|
||||||
|
### Typical workflow
|
||||||
|
|
||||||
|
1. Branch from `main`: `git checkout -b feature/<name>` (or `fix/`, `refactor/`).
|
||||||
|
2. Implement following the layered architecture and coding conventions (see
|
||||||
|
`docs/development-guidelines.md`):
|
||||||
|
- Route handlers in `app/api/.../route.ts` start with `export const runtime = 'nodejs';` (Edge
|
||||||
|
runtime is forbidden because of better-sqlite3).
|
||||||
|
- Services do permission checks and call repositories; repositories hold SQL and use parameter
|
||||||
|
binding (`@param`) and `deleted_at IS NULL` for soft-deleted tables.
|
||||||
|
- Map DB `snake_case` columns to camelCase entity fields in repository mappers.
|
||||||
|
- All list endpoints paginate (`LIMIT`/`OFFSET`).
|
||||||
|
3. Add Unit tests for any new Repository/Service, and an E2E spec for user-facing flows.
|
||||||
|
4. Ensure `npm run lint`, `npm run typecheck`, `npm test`, and `npm run build` are green.
|
||||||
|
5. Merge to `main` (merge commit). Milestones in this project were delivered one per branch, each
|
||||||
|
merged to `main` before the next branched off.
|
||||||
|
|
||||||
|
### Database migrations
|
||||||
|
|
||||||
|
Migrations are plain SQL files in `lib/db/migrations/`, named `NNN_description.sql` and run in
|
||||||
|
filename order. Each file runs in its own transaction and is recorded in `schema_migrations`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run migrate # apply pending migrations
|
||||||
|
```
|
||||||
|
|
||||||
|
To add a schema change, create e.g. `lib/db/migrations/002_add_column.sql` and re-run
|
||||||
|
`npm run migrate`. Never edit an already-applied migration — add a new one.
|
||||||
|
|
||||||
|
### Realtime (SSE)
|
||||||
|
|
||||||
|
`lib/sse/hub.ts` (`SseHub`) keeps an in-memory set of clients per project and broadcasts events only
|
||||||
|
to that project's clients. Services inject the hub and call `broadcast(projectId, event)`; the
|
||||||
|
stream endpoint `GET /api/projects/:projectId/chat/stream` registers a client and cleans up on
|
||||||
|
abort. The chat UI uses `EventSource` (auto-reconnect).
|
||||||
|
|
||||||
|
### Sessions & auth
|
||||||
|
|
||||||
|
Stateless signed-cookie sessions (`lib/auth/session.ts`): the cookie is
|
||||||
|
`base64url({uid,iat}).base64url(hmacSha256(secret, payload))`, verified server-side with
|
||||||
|
`crypto.timingSafeEqual` and an `iat` expiry check. `middleware.ts` does a coarse cookie-presence
|
||||||
|
redirect for pages; the real HMAC + DB verification happens in `getCurrentUser` (Node runtime).
|
||||||
|
|
||||||
|
### Where things live
|
||||||
|
|
||||||
|
| Concern | Location |
|
||||||
|
|---|---|
|
||||||
|
| Pages & API routes | `app/` |
|
||||||
|
| Business logic, permissions | `services/` |
|
||||||
|
| SQL & data access | `repositories/` |
|
||||||
|
| DB wrapper, migrations, SSE, auth, types, validators | `lib/` |
|
||||||
|
| React components | `components/` |
|
||||||
|
| Tests | `tests/unit`, `tests/integration`, `tests/e2e` |
|
||||||
|
|
||||||
|
### Spec-driven workflow (optional)
|
||||||
|
|
||||||
|
This project also ships an opencode spec-driven workflow (see `AGENTS.md`, `docs/`, `.steering/`,
|
||||||
|
`.opencode/`). Persistent design docs live in `docs/`; per-task plans live in
|
||||||
|
`.steering/[YYYYMMDD]-[name]/`. You can drive new work with `/add-feature <name>` in opencode, but
|
||||||
|
normal Git + `npm` development works too.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Configured via `.env` (copy from `.env.example`):
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `SQLITE_PATH` | `./data/app.db` | SQLite database file path |
|
||||||
|
| `SESSION_SECRET` | _(must set)_ | HMAC secret for signing session cookies |
|
||||||
|
| `UPLOADS_PATH` | `./data/uploads` | Directory for uploaded files |
|
||||||
|
|
||||||
|
`SESSION_SECRET` must be set — the app throws on startup if it is missing. Never commit `.env`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
.
|
.
|
||||||
├── opencode.json # opencode config (permissions, instructions)
|
├── app/ # Next.js App Router: pages + Route Handlers (all runtime='nodejs')
|
||||||
├── AGENTS.md # Project instructions (loaded into every session)
|
│ ├── api/ # REST/SSE endpoints
|
||||||
├── package.json # Node.js project + scripts
|
│ ├── projects/[projectId]/ # project screens (board, notes, chat, todos, files, calendar, ...)
|
||||||
├── tsconfig.json # TypeScript config
|
│ ├── admin/backups/ # admin-only backup screen
|
||||||
├── eslint.config.js # ESLint flat config
|
│ └── login / profile / dashboard / notifications
|
||||||
├── vitest.config.ts # Vitest config (80% coverage threshold)
|
├── lib/
|
||||||
├── .prettierrc / .prettierignore
|
│ ├── db/ # SqliteDatabase, Migrator, migrations/*.sql
|
||||||
├── .gitignore
|
│ ├── sse/ # SseHub
|
||||||
├── .husky/pre-commit # Runs lint-staged + typecheck before commit
|
│ ├── auth/ # session, getCurrentUser
|
||||||
├── .devcontainer/devcontainer.json
|
│ ├── types/ # entity & event types
|
||||||
├── prompt.md # Example MVP prompt
|
│ ├── validators/ # input validators
|
||||||
│
|
│ ├── api/ # service factories + error handling for route handlers
|
||||||
├── src/ # Source code
|
│ └── errors.ts # AppError hierarchy (ValidationError, ForbiddenError, ...)
|
||||||
│ ├── example.ts
|
├── repositories/ # one class per table (SQL + mapping)
|
||||||
│ └── example.test.ts
|
├── services/ # business logic + permission checks + side effects
|
||||||
│
|
├── components/ # React components by area (layout, board, chat, todo, ...)
|
||||||
├── docs/ # Persistent design documents (created by /setup-project)
|
├── tests/
|
||||||
│ └── ideas/
|
│ ├── unit/ # Vitest — mirrors source structure
|
||||||
│ └── initial-requirements.md # Brainstorming notes (pre-PRD)
|
│ ├── integration/ # Vitest — real temp SQLite DB
|
||||||
│
|
│ ├── e2e/ # Playwright specs + globalSetup.ts
|
||||||
├── .steering/ # Per-task steering files (created by /add-feature)
|
│ └── helpers/db.ts # createTestDb() / createMigratedTestDb()
|
||||||
│ └── .gitkeep
|
├── data/ # git-ignored: app.db, uploads/
|
||||||
│
|
├── backups/ # git-ignored: backup-<timestamp>.zip
|
||||||
└── .opencode/ # opencode configuration
|
├── docs/ # persistent design docs (PRD, architecture, milestones, ...)
|
||||||
├── agent/ # Subagent definitions
|
└── .steering/ # per-task plans (git-ignored working artifacts)
|
||||||
│ ├── doc-reviewer.md
|
|
||||||
│ └── implementation-validator.md
|
|
||||||
├── command/ # Slash commands
|
|
||||||
│ ├── setup-project.md
|
|
||||||
│ ├── add-feature.md
|
|
||||||
│ └── review-docs.md
|
|
||||||
└── skills/ # Task-specific skills
|
|
||||||
├── prd-writing/ (SKILL.md + template.md)
|
|
||||||
├── functional-design/ (SKILL.md + template.md + guide.md)
|
|
||||||
├── architecture-design/ (SKILL.md + template.md + guide.md)
|
|
||||||
├── repository-structure/ (SKILL.md + template.md + guide.md)
|
|
||||||
├── development-guidelines/(SKILL.md + template.md + guides/)
|
|
||||||
├── glossary-creation/ (SKILL.md + template.md + guide.md)
|
|
||||||
└── steering/ (SKILL.md + templates/)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Configuration Overview
|
## npm Scripts Reference
|
||||||
|
|
||||||
### `opencode.json`
|
| Script | Description |
|
||||||
|
|---|---|
|
||||||
The main configuration file. Key sections:
|
| `npm run dev` | Start the Next.js dev server (http://localhost:3000) |
|
||||||
|
| `npm run build` | Production build |
|
||||||
```jsonc
|
| `npm run start` | Serve the production build |
|
||||||
{
|
| `npm run lint` | ESLint |
|
||||||
"$schema": "https://opencode.ai/config.json",
|
| `npm run format` | Prettier (write) |
|
||||||
"instructions": ["AGENTS.md"], // Loaded into every session
|
| `npm run typecheck` | `tsc --noEmit` |
|
||||||
"permission": {
|
| `npm test` | Run unit/integration tests (Vitest, once) |
|
||||||
"skill": "allow", // Skills are auto-available
|
| `npm run test:watch` | Vitest watch mode |
|
||||||
"bash": { // Common dev commands pre-approved
|
| `npm run test:coverage` | Vitest with coverage (80% threshold on repos+services) |
|
||||||
"*": "ask",
|
| `npm run test:e2e` | Playwright E2E (headless, full suite) |
|
||||||
"npm *": "allow",
|
| `npm run test:e2e:ui` | Playwright interactive UI mode (screenshots + traces) |
|
||||||
"npx *": "allow",
|
| `npm run migrate` | Apply SQL migrations |
|
||||||
"git *": "allow",
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> **After editing `opencode.json`, quit and restart opencode** — config is loaded once at startup.
|
|
||||||
|
|
||||||
### `AGENTS.md`
|
|
||||||
|
|
||||||
The project memory file. opencode reads it on every session. It defines:
|
|
||||||
- The technology stack
|
|
||||||
- The spec-driven development principles
|
|
||||||
- The directory structure
|
|
||||||
- The development process and workflow
|
|
||||||
|
|
||||||
### Agents (`.opencode/agent/`)
|
|
||||||
|
|
||||||
Subagents run in isolated contexts via the `task` tool. They don't consume the main session's context.
|
|
||||||
|
|
||||||
### Commands (`.opencode/command/`)
|
|
||||||
|
|
||||||
Slash commands invoked in the opencode TUI with `/command-name`. Each command is a prompt template
|
|
||||||
that opencode executes.
|
|
||||||
|
|
||||||
### Skills (`.opencode/skills/`)
|
|
||||||
|
|
||||||
Skills are loaded automatically based on their `description` field. You don't invoke them by name —
|
|
||||||
opencode surfaces the right skill when the task matches.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Usage Workflow
|
|
||||||
|
|
||||||
### 1. Initial project setup
|
|
||||||
|
|
||||||
Run the setup command to create all six persistent design documents interactively:
|
|
||||||
|
|
||||||
```
|
|
||||||
> /setup-project
|
|
||||||
```
|
|
||||||
|
|
||||||
This creates (one at a time, with your approval):
|
|
||||||
1. `docs/product-requirements.md` (PRD)
|
|
||||||
2. `docs/functional-design.md`
|
|
||||||
3. `docs/architecture.md`
|
|
||||||
4. `docs/repository-structure.md`
|
|
||||||
5. `docs/development-guidelines.md`
|
|
||||||
6. `docs/glossary.md`
|
|
||||||
|
|
||||||
The PRD is based on the brainstorming notes in `docs/ideas/initial-requirements.md`. Edit that file
|
|
||||||
first if you have your own idea, or replace it.
|
|
||||||
|
|
||||||
### 2. Add a feature
|
|
||||||
|
|
||||||
```
|
|
||||||
> /add-feature User authentication
|
|
||||||
```
|
|
||||||
|
|
||||||
This fully autonomous workflow:
|
|
||||||
1. Creates `.steering/[YYYYMMDD]-[feature-name]/` with `requirements.md`, `design.md`, `tasklist.md`
|
|
||||||
2. Generates the steering files (planning mode of the steering skill)
|
|
||||||
3. Implements every task in `tasklist.md`, marking each `[ ]` → `[x]` as it goes
|
|
||||||
4. Launches the `implementation-validator` subagent for quality review
|
|
||||||
5. Runs `npm test`, `npm run lint`, `npm run typecheck`
|
|
||||||
6. Records a retrospective in `tasklist.md`
|
|
||||||
|
|
||||||
### 3. Review a document
|
|
||||||
|
|
||||||
```
|
|
||||||
> /review-docs docs/product-requirements.md
|
|
||||||
```
|
|
||||||
|
|
||||||
Launches the `doc-reviewer` subagent to evaluate the document from five perspectives:
|
|
||||||
completeness, clarity, consistency, implementability, and measurability.
|
|
||||||
|
|
||||||
### 4. Day-to-day editing
|
|
||||||
|
|
||||||
You don't always need a command — just ask in normal conversation:
|
|
||||||
|
|
||||||
```
|
|
||||||
> Add a new feature to the PRD
|
|
||||||
> Review the performance requirements in architecture.md
|
|
||||||
> Add a new domain term to glossary.md
|
|
||||||
```
|
|
||||||
|
|
||||||
opencode loads the appropriate skill automatically.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Commands Reference
|
|
||||||
|
|
||||||
| Command | Description | Example |
|
|
||||||
|---|---|---|
|
|
||||||
| `/setup-project` | Create the six persistent documents interactively | `/setup-project` |
|
|
||||||
| `/add-feature` | Implement a feature end-to-end (autonomous) | `/add-feature User profile editing` |
|
|
||||||
| `/review-docs` | Detailed document review via subagent | `/review-docs docs/architecture.md` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Skills Reference
|
|
||||||
|
|
||||||
| Skill | When it loads | Output |
|
|
||||||
|---|---|---|
|
|
||||||
| `prd-writing` | Creating a Product Requirements Document | `docs/product-requirements.md` |
|
|
||||||
| `functional-design` | Creating a functional design document | `docs/functional-design.md` |
|
|
||||||
| `architecture-design` | Designing the system architecture | `docs/architecture.md` |
|
|
||||||
| `repository-structure` | Defining the directory layout | `docs/repository-structure.md` |
|
|
||||||
| `development-guidelines` | Creating coding conventions / implementing code | `docs/development-guidelines.md` |
|
|
||||||
| `glossary-creation` | Creating a project glossary | `docs/glossary.md` |
|
|
||||||
| `steering` | Planning, implementing, and retrospecting on a task | `.steering/[date]-[name]/` files |
|
|
||||||
|
|
||||||
Each skill folder contains a `SKILL.md` (instructions) plus `template.md` and/or `guide.md` (reference
|
|
||||||
material the skill points to).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Agents Reference
|
|
||||||
|
|
||||||
| Agent | Mode | Purpose |
|
|
||||||
|---|---|---|
|
|
||||||
| `doc-reviewer` | subagent | Reviews document quality across 5 dimensions; outputs a scored report with prioritized improvements |
|
|
||||||
| `implementation-validator` | subagent | Validates code against the spec; checks quality, test coverage, security, performance; runs lint/test/typecheck |
|
|
||||||
|
|
||||||
Agents are launched via the `task` tool (e.g., by the `/add-feature` and `/review-docs` commands).
|
|
||||||
They run in an isolated context window.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Customizing the Boilerplate
|
|
||||||
|
|
||||||
### Change the model
|
|
||||||
|
|
||||||
Agents inherit the default model from `opencode.json`. To pin a specific model for a subagent, add
|
|
||||||
`model` to its frontmatter:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
---
|
|
||||||
description: ...
|
|
||||||
mode: subagent
|
|
||||||
model: anthropic/claude-sonnet-4-6
|
|
||||||
---
|
|
||||||
```
|
|
||||||
|
|
||||||
Or set a global default in `opencode.json`:
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"model": "anthropic/claude-sonnet-4-6"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> Model IDs use the `provider/model-id` format. See <https://opencode.ai/config.json> for the full schema.
|
|
||||||
|
|
||||||
### Adjust permissions
|
|
||||||
|
|
||||||
Edit the `permission` block in `opencode.json`. For example, to allow all bash commands:
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"permission": { "bash": "allow" }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Add a new skill
|
|
||||||
|
|
||||||
1. Create `.opencode/skills/my-skill/SKILL.md`
|
|
||||||
2. Add frontmatter with `name` and `description` (the description controls when opencode loads it)
|
|
||||||
3. Add any companion `template.md` or `guide.md` files
|
|
||||||
4. Restart opencode
|
|
||||||
|
|
||||||
### Add a new command
|
|
||||||
|
|
||||||
1. Create `.opencode/command/my-command.md`
|
|
||||||
2. Add `description` frontmatter + a prompt body (use `$ARGUMENTS` for user input)
|
|
||||||
3. Restart opencode
|
|
||||||
|
|
||||||
### Add a new agent
|
|
||||||
|
|
||||||
1. Create `.opencode/agent/my-agent.md`
|
|
||||||
2. Add `description` and `mode: subagent` frontmatter + a prompt body
|
|
||||||
3. Restart opencode
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### opencode won't start after editing config
|
- **App won't start: `SESSION_SECRET is not configured`** — create `.env` from `.env.example` and
|
||||||
|
set `SESSION_SECRET`.
|
||||||
opencode validates `opencode.json` strictly and refuses to start on invalid fields. To recover:
|
- **`npm run migrate` fails with "more than one statement"** — ensure the migrator uses
|
||||||
|
`SqliteDatabase.exec()` (it does); this is a historical footgun with `better-sqlite3`'s
|
||||||
```bash
|
`prepare()`.
|
||||||
# Skip the project config and start from globals only
|
- **E2E: `Executable doesn't exist`** — run `npx playwright install chromium`.
|
||||||
OPENCODE_DISABLE_PROJECT_CONFIG=1 opencode
|
- **E2E: tests flaky/failing** — the suite is pinned to `workers: 1`; ensure no other `npm run dev`
|
||||||
```
|
is occupying port 3000, and remove `./data/` before a clean run.
|
||||||
|
- **Port 3000 busy** — stop the other process (`lsof -ti:3000 | xargs kill`) before running dev/E2E.
|
||||||
Then fix the broken file and restart normally.
|
|
||||||
|
|
||||||
### Skills not loading
|
|
||||||
|
|
||||||
- Ensure each `SKILL.md` has a `description` in its frontmatter — skills without one are filtered out.
|
|
||||||
- Ensure the folder name matches the `name` field.
|
|
||||||
- Restart opencode after adding or changing skills.
|
|
||||||
|
|
||||||
### Commands not appearing
|
|
||||||
|
|
||||||
- Command files must be directly inside `.opencode/command/` (not in subdirectories).
|
|
||||||
- Restart opencode after adding commands.
|
|
||||||
|
|
||||||
### Dev Container: `opencode` command not found
|
|
||||||
|
|
||||||
The install script adds opencode to `~/.opencode/bin/` and updates your shell profile. If the command
|
|
||||||
isn't found in a new terminal:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export PATH="$HOME/.opencode/bin:$PATH"
|
|
||||||
opencode
|
|
||||||
```
|
|
||||||
|
|
||||||
Or create a permanent symlink:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo ln -sf "$HOME/.opencode/bin/opencode" /usr/local/bin/opencode
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
27
app/api/admin/migrations/route.ts
Normal file
27
app/api/admin/migrations/route.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { Migrator } from '@/lib/db/migrator';
|
||||||
|
import { getDb } from '@/lib/db/sqlite';
|
||||||
|
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { UnauthorizedError, ForbiddenError } from '@/lib/errors';
|
||||||
|
import { handleApiError } from '@/lib/api/handleError';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) {
|
||||||
|
return handleApiError(new UnauthorizedError());
|
||||||
|
}
|
||||||
|
if (user.role !== 'system_admin') {
|
||||||
|
return handleApiError(new ForbiddenError('管理者のみアクセス可能です'));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const migrationsDir = path.join(process.cwd(), 'lib', 'db', 'migrations');
|
||||||
|
const migrator = new Migrator(getDb(), migrationsDir);
|
||||||
|
return NextResponse.json({ migrations: migrator.getAppliedMigrations() });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/api/auth/login/route.ts
Normal file
34
app/api/auth/login/route.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { AuthService } from '@/services/AuthService';
|
||||||
|
import { UserRepository } from '@/repositories/UserRepository';
|
||||||
|
import { getDb } from '@/lib/db/sqlite';
|
||||||
|
import { toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { setSessionCookie } from '@/lib/auth/session';
|
||||||
|
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return jsonError(400, 'リクエスト本文が不正です');
|
||||||
|
}
|
||||||
|
|
||||||
|
const authService = new AuthService(new UserRepository(getDb()));
|
||||||
|
try {
|
||||||
|
const { user, token } = authService.login(
|
||||||
|
String(body.email ?? ''),
|
||||||
|
String(body.password ?? '')
|
||||||
|
);
|
||||||
|
const response = NextResponse.json(
|
||||||
|
{ user: toPublicUser(user) },
|
||||||
|
{ status: 200 }
|
||||||
|
);
|
||||||
|
setSessionCookie(response, token);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
app/api/auth/logout/route.ts
Normal file
10
app/api/auth/logout/route.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { clearSessionCookie } from '@/lib/auth/session';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
const response = NextResponse.json({ ok: true });
|
||||||
|
clearSessionCookie(response);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
14
app/api/auth/me/route.ts
Normal file
14
app/api/auth/me/route.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { UnauthorizedError } from '@/lib/errors';
|
||||||
|
import { handleApiError } from '@/lib/api/handleError';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) {
|
||||||
|
return handleApiError(new UnauthorizedError());
|
||||||
|
}
|
||||||
|
return NextResponse.json({ user: toPublicUser(user) });
|
||||||
|
}
|
||||||
36
app/api/auth/register/route.ts
Normal file
36
app/api/auth/register/route.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { AuthService } from '@/services/AuthService';
|
||||||
|
import { UserRepository } from '@/repositories/UserRepository';
|
||||||
|
import { getDb } from '@/lib/db/sqlite';
|
||||||
|
import { toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createSessionToken, setSessionCookie } from '@/lib/auth/session';
|
||||||
|
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return jsonError(400, 'リクエスト本文が不正です');
|
||||||
|
}
|
||||||
|
|
||||||
|
const authService = new AuthService(new UserRepository(getDb()));
|
||||||
|
try {
|
||||||
|
const user = authService.register({
|
||||||
|
name: String(body.name ?? ''),
|
||||||
|
email: String(body.email ?? ''),
|
||||||
|
password: String(body.password ?? ''),
|
||||||
|
});
|
||||||
|
// 登録成功と同時にログイン(セッションCookieを設定)
|
||||||
|
const response = NextResponse.json(
|
||||||
|
{ user: toPublicUser(user) },
|
||||||
|
{ status: 201 }
|
||||||
|
);
|
||||||
|
setSessionCookie(response, createSessionToken(user.id));
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/api/dashboard/route.ts
Normal file
19
app/api/dashboard/route.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createDashboardService } from '@/lib/api/services';
|
||||||
|
import { UnauthorizedError } from '@/lib/errors';
|
||||||
|
import { handleApiError } from '@/lib/api/handleError';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
|
||||||
|
const service = createDashboardService();
|
||||||
|
try {
|
||||||
|
return NextResponse.json(service.getPersonalDashboard(user.id));
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/api/files/[fileId]/download/route.ts
Normal file
32
app/api/files/[fileId]/download/route.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createFileStorageService } from '@/lib/api/services';
|
||||||
|
import { UnauthorizedError } from '@/lib/errors';
|
||||||
|
import { handleApiError } from '@/lib/api/handleError';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ fileId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { fileId } = await params;
|
||||||
|
|
||||||
|
const service = createFileStorageService();
|
||||||
|
try {
|
||||||
|
const file = service.getFileInfo(user.id, Number(fileId));
|
||||||
|
const body = fs.readFileSync(file.path);
|
||||||
|
return new NextResponse(body, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': file.mimeType,
|
||||||
|
'Content-Disposition': `inline; filename="${file.originalName}"`,
|
||||||
|
'Content-Length': String(file.size),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
app/api/files/[fileId]/route.ts
Normal file
24
app/api/files/[fileId]/route.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createFileStorageService } from '@/lib/api/services';
|
||||||
|
import { UnauthorizedError } from '@/lib/errors';
|
||||||
|
import { handleApiError } from '@/lib/api/handleError';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ fileId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { fileId } = await params;
|
||||||
|
|
||||||
|
const service = createFileStorageService();
|
||||||
|
try {
|
||||||
|
service.delete(user.id, Number(fileId));
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/api/notifications/[id]/read/route.ts
Normal file
25
app/api/notifications/[id]/read/route.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createNotificationService } from '@/lib/api/services';
|
||||||
|
import { UnauthorizedError, NotFoundError } from '@/lib/errors';
|
||||||
|
import { handleApiError } from '@/lib/api/handleError';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) {
|
||||||
|
return handleApiError(new UnauthorizedError());
|
||||||
|
}
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const service = createNotificationService();
|
||||||
|
const updated = service.markRead(Number(id), user.id);
|
||||||
|
if (!updated) {
|
||||||
|
return handleApiError(new NotFoundError('Notification', id));
|
||||||
|
}
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
19
app/api/notifications/route.ts
Normal file
19
app/api/notifications/route.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createNotificationService } from '@/lib/api/services';
|
||||||
|
import { UnauthorizedError } from '@/lib/errors';
|
||||||
|
import { handleApiError } from '@/lib/api/handleError';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) {
|
||||||
|
return handleApiError(new UnauthorizedError());
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = Number(request.nextUrl.searchParams.get('page') ?? '1') || 1;
|
||||||
|
const service = createNotificationService();
|
||||||
|
const result = service.listUnread(user.id, page);
|
||||||
|
return NextResponse.json(result);
|
||||||
|
}
|
||||||
34
app/api/projects/[projectId]/activity/route.ts
Normal file
34
app/api/projects/[projectId]/activity/route.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import {
|
||||||
|
createProjectService,
|
||||||
|
createActivityLogService,
|
||||||
|
} from '@/lib/api/services';
|
||||||
|
import { UnauthorizedError } from '@/lib/errors';
|
||||||
|
import { handleApiError } from '@/lib/api/handleError';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) {
|
||||||
|
return handleApiError(new UnauthorizedError());
|
||||||
|
}
|
||||||
|
const { projectId } = await params;
|
||||||
|
|
||||||
|
// メンバーシップ確認(非参加者は403)
|
||||||
|
const projectService = createProjectService();
|
||||||
|
try {
|
||||||
|
projectService.getProject(user.id, Number(projectId));
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = Number(request.nextUrl.searchParams.get('page') ?? '1') || 1;
|
||||||
|
const activityService = createActivityLogService();
|
||||||
|
const result = activityService.listByProject(Number(projectId), page);
|
||||||
|
return NextResponse.json(result);
|
||||||
|
}
|
||||||
41
app/api/projects/[projectId]/attachments/route.ts
Normal file
41
app/api/projects/[projectId]/attachments/route.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createFileStorageService } from '@/lib/api/services';
|
||||||
|
import { UnauthorizedError, ValidationError } from '@/lib/errors';
|
||||||
|
import { handleApiError } from '@/lib/api/handleError';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* チャット/掲示板添付用のファイルアップロード。
|
||||||
|
* Files一覧公開用の通知/SSE/アクティビティを行わず、source='attachment'で保存する。
|
||||||
|
*/
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { projectId } = await params;
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const file = formData.get('file');
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
return handleApiError(
|
||||||
|
new ValidationError('ファイルを指定してください', 'file')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = Buffer.from(await file.arrayBuffer());
|
||||||
|
const service = createFileStorageService();
|
||||||
|
try {
|
||||||
|
const fileAsset = service.uploadForAttachment(user.id, Number(projectId), {
|
||||||
|
originalName: file.name,
|
||||||
|
mimeType: file.type || 'application/octet-stream',
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ file: fileAsset }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createBoardService } from '@/lib/api/services';
|
||||||
|
import { UnauthorizedError } from '@/lib/errors';
|
||||||
|
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string; commentId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { commentId } = await params;
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return jsonError(400, 'リクエスト本文が不正です');
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = createBoardService();
|
||||||
|
try {
|
||||||
|
const comment = service.updateComment(
|
||||||
|
user.id,
|
||||||
|
Number(commentId),
|
||||||
|
String(body.bodyMd ?? '')
|
||||||
|
);
|
||||||
|
return NextResponse.json({ comment });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string; commentId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { commentId } = await params;
|
||||||
|
|
||||||
|
const service = createBoardService();
|
||||||
|
try {
|
||||||
|
service.deleteComment(user.id, Number(commentId));
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createBoardService } from '@/lib/api/services';
|
||||||
|
import { UnauthorizedError } from '@/lib/errors';
|
||||||
|
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string; threadId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { threadId } = await params;
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return jsonError(400, 'リクエスト本文が不正です');
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = createBoardService();
|
||||||
|
try {
|
||||||
|
const fileIds = Array.isArray(body.fileIds)
|
||||||
|
? body.fileIds
|
||||||
|
.map((n) => Number(n))
|
||||||
|
.filter((n) => Number.isFinite(n) && n > 0)
|
||||||
|
: [];
|
||||||
|
const comment = service.createComment(
|
||||||
|
user.id,
|
||||||
|
Number(threadId),
|
||||||
|
String(body.bodyMd ?? ''),
|
||||||
|
fileIds
|
||||||
|
);
|
||||||
|
return NextResponse.json({ comment }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createBoardService } from '@/lib/api/services';
|
||||||
|
import { UnauthorizedError } from '@/lib/errors';
|
||||||
|
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string; threadId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { threadId } = await params;
|
||||||
|
|
||||||
|
const service = createBoardService();
|
||||||
|
try {
|
||||||
|
const thread = service.getThread(user.id, Number(threadId));
|
||||||
|
const comments = service.listComments(user.id, Number(threadId));
|
||||||
|
return NextResponse.json({ thread, comments });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string; threadId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { threadId } = await params;
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return jsonError(400, 'リクエスト本文が不正です');
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = createBoardService();
|
||||||
|
try {
|
||||||
|
const thread = service.updateThread(user.id, Number(threadId), {
|
||||||
|
title: typeof body.title === 'string' ? body.title : undefined,
|
||||||
|
bodyMd: typeof body.bodyMd === 'string' ? body.bodyMd : undefined,
|
||||||
|
category:
|
||||||
|
typeof body.category === 'string'
|
||||||
|
? (body.category as never)
|
||||||
|
: undefined,
|
||||||
|
isPinned: typeof body.isPinned === 'number' ? body.isPinned : undefined,
|
||||||
|
isImportant:
|
||||||
|
typeof body.isImportant === 'number' ? body.isImportant : undefined,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ thread });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string; threadId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { threadId } = await params;
|
||||||
|
|
||||||
|
const service = createBoardService();
|
||||||
|
try {
|
||||||
|
service.deleteThread(user.id, Number(threadId));
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
63
app/api/projects/[projectId]/board/threads/route.ts
Normal file
63
app/api/projects/[projectId]/board/threads/route.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createBoardService } from '@/lib/api/services';
|
||||||
|
import { UnauthorizedError } from '@/lib/errors';
|
||||||
|
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { projectId } = await params;
|
||||||
|
const page = Number(request.nextUrl.searchParams.get('page') ?? '1') || 1;
|
||||||
|
const search = request.nextUrl.searchParams.get('q') ?? undefined;
|
||||||
|
|
||||||
|
const service = createBoardService();
|
||||||
|
try {
|
||||||
|
return NextResponse.json(
|
||||||
|
service.listThreads(user.id, Number(projectId), { page, search })
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { projectId } = await params;
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return jsonError(400, 'リクエスト本文が不正です');
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = createBoardService();
|
||||||
|
try {
|
||||||
|
const fileIds = Array.isArray(body.fileIds)
|
||||||
|
? body.fileIds
|
||||||
|
.map((n) => Number(n))
|
||||||
|
.filter((n) => Number.isFinite(n) && n > 0)
|
||||||
|
: [];
|
||||||
|
const thread = service.createThread(user.id, Number(projectId), {
|
||||||
|
title: String(body.title ?? ''),
|
||||||
|
bodyMd: String(body.bodyMd ?? ''),
|
||||||
|
category:
|
||||||
|
typeof body.category === 'string'
|
||||||
|
? (body.category as never)
|
||||||
|
: undefined,
|
||||||
|
fileIds,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ thread }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createScheduleService } from '@/lib/api/services';
|
||||||
|
import { UnauthorizedError } from '@/lib/errors';
|
||||||
|
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string; eventId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { eventId } = await params;
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return jsonError(400, 'リクエスト本文が不正です');
|
||||||
|
}
|
||||||
|
const service = createScheduleService();
|
||||||
|
try {
|
||||||
|
const event = service.updateEvent(user.id, Number(eventId), {
|
||||||
|
title: typeof body.title === 'string' ? body.title : undefined,
|
||||||
|
description:
|
||||||
|
typeof body.description === 'string' ? body.description : undefined,
|
||||||
|
startAt: typeof body.startAt === 'string' ? body.startAt : undefined,
|
||||||
|
endAt:
|
||||||
|
typeof body.endAt === 'string'
|
||||||
|
? body.endAt
|
||||||
|
: body.endAt === null
|
||||||
|
? null
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ event });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string; eventId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { eventId } = await params;
|
||||||
|
const service = createScheduleService();
|
||||||
|
try {
|
||||||
|
service.deleteEvent(user.id, Number(eventId));
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
app/api/projects/[projectId]/calendar/events/route.ts
Normal file
56
app/api/projects/[projectId]/calendar/events/route.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createScheduleService } from '@/lib/api/services';
|
||||||
|
import { UnauthorizedError } from '@/lib/errors';
|
||||||
|
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { projectId } = await params;
|
||||||
|
const from = request.nextUrl.searchParams.get('from') ?? '';
|
||||||
|
const to = request.nextUrl.searchParams.get('to') ?? '';
|
||||||
|
const service = createScheduleService();
|
||||||
|
try {
|
||||||
|
return NextResponse.json(
|
||||||
|
service.getCalendarEvents(user.id, Number(projectId), { from, to })
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { projectId } = await params;
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return jsonError(400, 'リクエスト本文が不正です');
|
||||||
|
}
|
||||||
|
const service = createScheduleService();
|
||||||
|
try {
|
||||||
|
const event = service.createEvent(user.id, {
|
||||||
|
projectId: Number(projectId),
|
||||||
|
title: String(body.title ?? ''),
|
||||||
|
type: (typeof body.type === 'string' ? body.type : 'custom') as never,
|
||||||
|
startAt: String(body.startAt ?? ''),
|
||||||
|
endAt: typeof body.endAt === 'string' ? body.endAt : null,
|
||||||
|
description:
|
||||||
|
typeof body.description === 'string' ? body.description : null,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ event }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createChatService } from '@/lib/api/services';
|
||||||
|
import { UnauthorizedError } from '@/lib/errors';
|
||||||
|
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string; messageId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { messageId } = await params;
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return jsonError(400, 'リクエスト本文が不正です');
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = createChatService();
|
||||||
|
try {
|
||||||
|
const message = service.editMessage(
|
||||||
|
user.id,
|
||||||
|
Number(messageId),
|
||||||
|
String(body.body ?? '')
|
||||||
|
);
|
||||||
|
return NextResponse.json({ message });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string; messageId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { messageId } = await params;
|
||||||
|
|
||||||
|
const service = createChatService();
|
||||||
|
try {
|
||||||
|
service.deleteMessage(user.id, Number(messageId));
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
app/api/projects/[projectId]/chat/messages/route.ts
Normal file
60
app/api/projects/[projectId]/chat/messages/route.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createChatService } from '@/lib/api/services';
|
||||||
|
import { UnauthorizedError } from '@/lib/errors';
|
||||||
|
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { projectId } = await params;
|
||||||
|
const page = Number(request.nextUrl.searchParams.get('page') ?? '1') || 1;
|
||||||
|
const search = request.nextUrl.searchParams.get('q') ?? undefined;
|
||||||
|
|
||||||
|
const service = createChatService();
|
||||||
|
try {
|
||||||
|
return NextResponse.json(
|
||||||
|
service.getHistory(user.id, Number(projectId), { page, search })
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { projectId } = await params;
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return jsonError(400, 'リクエスト本文が不正です');
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = createChatService();
|
||||||
|
try {
|
||||||
|
const fileIds = Array.isArray(body.fileIds)
|
||||||
|
? body.fileIds
|
||||||
|
.map((n) => Number(n))
|
||||||
|
.filter((n) => Number.isFinite(n) && n > 0)
|
||||||
|
: [];
|
||||||
|
const message = service.sendMessage(
|
||||||
|
user.id,
|
||||||
|
Number(projectId),
|
||||||
|
String(body.body ?? ''),
|
||||||
|
fileIds
|
||||||
|
);
|
||||||
|
return NextResponse.json({ message }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
70
app/api/projects/[projectId]/chat/stream/route.ts
Normal file
70
app/api/projects/[projectId]/chat/stream/route.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createProjectService } from '@/lib/api/services';
|
||||||
|
import { getSseHub } from '@/lib/sse/hub';
|
||||||
|
import { UnauthorizedError } from '@/lib/errors';
|
||||||
|
import { handleApiError } from '@/lib/api/handleError';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { projectId } = await params;
|
||||||
|
|
||||||
|
const projectService = createProjectService();
|
||||||
|
try {
|
||||||
|
projectService.getProject(user.id, Number(projectId));
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pid = Number(projectId);
|
||||||
|
const hub = getSseHub();
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
|
const stream = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
const client = {
|
||||||
|
enqueue: (chunk: string) => controller.enqueue(encoder.encode(chunk)),
|
||||||
|
close: () => {
|
||||||
|
try {
|
||||||
|
controller.close();
|
||||||
|
} catch {
|
||||||
|
// 既に閉じられている
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
hub.addClient(pid, client);
|
||||||
|
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
try {
|
||||||
|
controller.enqueue(encoder.encode(': ping\n\n'));
|
||||||
|
} catch {
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
}
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
request.signal.addEventListener('abort', () => {
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
hub.removeClient(pid, client);
|
||||||
|
try {
|
||||||
|
controller.close();
|
||||||
|
} catch {
|
||||||
|
// 既に閉じられている
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache, no-transform',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
55
app/api/projects/[projectId]/files/route.ts
Normal file
55
app/api/projects/[projectId]/files/route.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createFileStorageService } from '@/lib/api/services';
|
||||||
|
import { UnauthorizedError, ValidationError } from '@/lib/errors';
|
||||||
|
import { handleApiError } from '@/lib/api/handleError';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { projectId } = await params;
|
||||||
|
const page = Number(request.nextUrl.searchParams.get('page') ?? '1') || 1;
|
||||||
|
const service = createFileStorageService();
|
||||||
|
try {
|
||||||
|
return NextResponse.json(
|
||||||
|
service.listFiles(user.id, Number(projectId), page)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { projectId } = await params;
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const file = formData.get('file');
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
return handleApiError(
|
||||||
|
new ValidationError('ファイルを指定してください', 'file')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = Buffer.from(await file.arrayBuffer());
|
||||||
|
const service = createFileStorageService();
|
||||||
|
try {
|
||||||
|
const fileAsset = service.upload(user.id, Number(projectId), {
|
||||||
|
originalName: file.name,
|
||||||
|
mimeType: file.type || 'application/octet-stream',
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ file: fileAsset }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
75
app/api/projects/[projectId]/meetings/[id]/route.ts
Normal file
75
app/api/projects/[projectId]/meetings/[id]/route.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createMeetingService } from '@/lib/api/services';
|
||||||
|
import { UnauthorizedError } from '@/lib/errors';
|
||||||
|
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string; id: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { id } = await params;
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return jsonError(400, 'リクエスト本文が不正です');
|
||||||
|
}
|
||||||
|
const service = createMeetingService();
|
||||||
|
try {
|
||||||
|
if (body.minutesMd !== undefined) {
|
||||||
|
const meeting = service.updateMinutes(
|
||||||
|
user.id,
|
||||||
|
Number(id),
|
||||||
|
String(body.minutesMd ?? '')
|
||||||
|
);
|
||||||
|
return NextResponse.json({ meeting });
|
||||||
|
}
|
||||||
|
const meeting = service.updateMeeting(user.id, Number(id), {
|
||||||
|
title: typeof body.title === 'string' ? body.title : undefined,
|
||||||
|
startAt: typeof body.startAt === 'string' ? body.startAt : undefined,
|
||||||
|
endAt: typeof body.endAt === 'string' ? body.endAt : undefined,
|
||||||
|
agendaMd: typeof body.agendaMd === 'string' ? body.agendaMd : undefined,
|
||||||
|
description:
|
||||||
|
typeof body.description === 'string' ? body.description : undefined,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ meeting });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string; id: string }> }
|
||||||
|
) {
|
||||||
|
// /api/projects/:projectId/meetings/check は専用ルートで処理するが、
|
||||||
|
// ここでは meetingId='check' の場合は重複チェックのみを行う
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { projectId, id } = await params;
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return jsonError(400, 'リクエスト本文が不正です');
|
||||||
|
}
|
||||||
|
const service = createMeetingService();
|
||||||
|
try {
|
||||||
|
const conflicts = service.checkScheduleConflicts(
|
||||||
|
user.id,
|
||||||
|
Number(projectId),
|
||||||
|
Array.isArray(body.memberIds) ? body.memberIds.map(Number) : [],
|
||||||
|
String(body.startAt ?? ''),
|
||||||
|
String(body.endAt ?? ''),
|
||||||
|
id === 'check' ? undefined : Number(id)
|
||||||
|
);
|
||||||
|
return NextResponse.json({ conflicts });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
app/api/projects/[projectId]/meetings/route.ts
Normal file
58
app/api/projects/[projectId]/meetings/route.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createMeetingService } from '@/lib/api/services';
|
||||||
|
import { UnauthorizedError } from '@/lib/errors';
|
||||||
|
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { projectId } = await params;
|
||||||
|
const service = createMeetingService();
|
||||||
|
try {
|
||||||
|
return NextResponse.json({
|
||||||
|
meetings: service.getMeetings(user.id, Number(projectId)),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { projectId } = await params;
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return jsonError(400, 'リクエスト本文が不正です');
|
||||||
|
}
|
||||||
|
const service = createMeetingService();
|
||||||
|
try {
|
||||||
|
const result = service.createMeeting(user.id, Number(projectId), {
|
||||||
|
title: String(body.title ?? ''),
|
||||||
|
startAt: String(body.startAt ?? ''),
|
||||||
|
endAt: String(body.endAt ?? ''),
|
||||||
|
description:
|
||||||
|
typeof body.description === 'string' ? body.description : null,
|
||||||
|
location: typeof body.location === 'string' ? body.location : null,
|
||||||
|
meetingUrl: typeof body.meetingUrl === 'string' ? body.meetingUrl : null,
|
||||||
|
agendaMd: typeof body.agendaMd === 'string' ? body.agendaMd : null,
|
||||||
|
memberIds: Array.isArray(body.memberIds)
|
||||||
|
? body.memberIds.map(Number)
|
||||||
|
: [],
|
||||||
|
});
|
||||||
|
return NextResponse.json(result, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/api/projects/[projectId]/members/[userId]/route.ts
Normal file
30
app/api/projects/[projectId]/members/[userId]/route.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createProjectService } from '@/lib/api/services';
|
||||||
|
import { UnauthorizedError } from '@/lib/errors';
|
||||||
|
import { handleApiError } from '@/lib/api/handleError';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: NextRequest,
|
||||||
|
{
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ projectId: string; userId: string }>;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) {
|
||||||
|
return handleApiError(new UnauthorizedError());
|
||||||
|
}
|
||||||
|
const { projectId, userId } = await params;
|
||||||
|
|
||||||
|
const service = createProjectService();
|
||||||
|
try {
|
||||||
|
service.removeMember(user.id, Number(projectId), Number(userId));
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
69
app/api/projects/[projectId]/members/route.ts
Normal file
69
app/api/projects/[projectId]/members/route.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createProjectService, createUserRepository } from '@/lib/api/services';
|
||||||
|
import { validateProjectMemberRole } from '@/lib/validators/projectValidator';
|
||||||
|
import { UnauthorizedError, NotFoundError } from '@/lib/errors';
|
||||||
|
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) {
|
||||||
|
return handleApiError(new UnauthorizedError());
|
||||||
|
}
|
||||||
|
const { projectId } = await params;
|
||||||
|
|
||||||
|
const service = createProjectService();
|
||||||
|
try {
|
||||||
|
const members = service.getMembers(user.id, Number(projectId));
|
||||||
|
return NextResponse.json({ members });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) {
|
||||||
|
return handleApiError(new UnauthorizedError());
|
||||||
|
}
|
||||||
|
const { projectId } = await params;
|
||||||
|
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return jsonError(400, 'リクエスト本文が不正です');
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = String(body.email ?? '');
|
||||||
|
const role = validateProjectMemberRole(
|
||||||
|
typeof body.role === 'string' ? body.role : 'member'
|
||||||
|
);
|
||||||
|
|
||||||
|
// メールアドレスからユーザーを解決する
|
||||||
|
const targetUser = createUserRepository().findByEmail(email);
|
||||||
|
if (!targetUser) {
|
||||||
|
return handleApiError(new NotFoundError('User', email));
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = createProjectService();
|
||||||
|
try {
|
||||||
|
const member = service.addMember(
|
||||||
|
user.id,
|
||||||
|
Number(projectId),
|
||||||
|
targetUser.id,
|
||||||
|
role
|
||||||
|
);
|
||||||
|
return NextResponse.json({ member }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
app/api/projects/[projectId]/milestones/[id]/route.ts
Normal file
60
app/api/projects/[projectId]/milestones/[id]/route.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createScheduleService } from '@/lib/api/services';
|
||||||
|
import { UnauthorizedError } from '@/lib/errors';
|
||||||
|
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string; id: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { id } = await params;
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return jsonError(400, 'リクエスト本文が不正です');
|
||||||
|
}
|
||||||
|
const service = createScheduleService();
|
||||||
|
try {
|
||||||
|
const milestone = service.updateMilestone(user.id, Number(id), {
|
||||||
|
title: typeof body.title === 'string' ? body.title : undefined,
|
||||||
|
dueDate:
|
||||||
|
typeof body.dueDate === 'string'
|
||||||
|
? body.dueDate
|
||||||
|
: body.dueDate === null
|
||||||
|
? null
|
||||||
|
: undefined,
|
||||||
|
status:
|
||||||
|
typeof body.status === 'string'
|
||||||
|
? (body.status as 'open' | 'closed')
|
||||||
|
: undefined,
|
||||||
|
description:
|
||||||
|
typeof body.description === 'string' ? body.description : undefined,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ milestone });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string; id: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { id } = await params;
|
||||||
|
const service = createScheduleService();
|
||||||
|
try {
|
||||||
|
return NextResponse.json({
|
||||||
|
progress: service.getMilestoneProgress(user.id, Number(id)),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/api/projects/[projectId]/milestones/route.ts
Normal file
51
app/api/projects/[projectId]/milestones/route.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createScheduleService } from '@/lib/api/services';
|
||||||
|
import { UnauthorizedError } from '@/lib/errors';
|
||||||
|
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { projectId } = await params;
|
||||||
|
const service = createScheduleService();
|
||||||
|
try {
|
||||||
|
return NextResponse.json({
|
||||||
|
milestones: service.getMilestones(user.id, Number(projectId)),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { projectId } = await params;
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return jsonError(400, 'リクエスト本文が不正です');
|
||||||
|
}
|
||||||
|
const service = createScheduleService();
|
||||||
|
try {
|
||||||
|
const milestone = service.createMilestone(user.id, Number(projectId), {
|
||||||
|
title: String(body.title ?? ''),
|
||||||
|
dueDate: typeof body.dueDate === 'string' ? body.dueDate : null,
|
||||||
|
description:
|
||||||
|
typeof body.description === 'string' ? body.description : null,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ milestone }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
69
app/api/projects/[projectId]/notes/[noteId]/route.ts
Normal file
69
app/api/projects/[projectId]/notes/[noteId]/route.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createNoteService } from '@/lib/api/services';
|
||||||
|
import { UnauthorizedError } from '@/lib/errors';
|
||||||
|
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string; noteId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { noteId } = await params;
|
||||||
|
|
||||||
|
const service = createNoteService();
|
||||||
|
try {
|
||||||
|
const note = service.getNote(user.id, Number(noteId));
|
||||||
|
return NextResponse.json({ note });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string; noteId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { noteId } = await params;
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return jsonError(400, 'リクエスト本文が不正です');
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = createNoteService();
|
||||||
|
try {
|
||||||
|
const note = service.updateNote(user.id, Number(noteId), {
|
||||||
|
title: typeof body.title === 'string' ? body.title : undefined,
|
||||||
|
bodyMd: typeof body.bodyMd === 'string' ? body.bodyMd : undefined,
|
||||||
|
tags: typeof body.tags === 'string' ? body.tags : undefined,
|
||||||
|
isPinned: typeof body.isPinned === 'number' ? body.isPinned : undefined,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ note });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string; noteId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { noteId } = await params;
|
||||||
|
|
||||||
|
const service = createNoteService();
|
||||||
|
try {
|
||||||
|
service.deleteNote(user.id, Number(noteId));
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/api/projects/[projectId]/notes/route.ts
Normal file
54
app/api/projects/[projectId]/notes/route.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createNoteService } from '@/lib/api/services';
|
||||||
|
import { UnauthorizedError } from '@/lib/errors';
|
||||||
|
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { projectId } = await params;
|
||||||
|
const page = Number(request.nextUrl.searchParams.get('page') ?? '1') || 1;
|
||||||
|
const search = request.nextUrl.searchParams.get('q') ?? undefined;
|
||||||
|
|
||||||
|
const service = createNoteService();
|
||||||
|
try {
|
||||||
|
return NextResponse.json(
|
||||||
|
service.listNotes(user.id, Number(projectId), { page, search })
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { projectId } = await params;
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return jsonError(400, 'リクエスト本文が不正です');
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = createNoteService();
|
||||||
|
try {
|
||||||
|
const note = service.createNote(user.id, Number(projectId), {
|
||||||
|
title: String(body.title ?? ''),
|
||||||
|
bodyMd: String(body.bodyMd ?? ''),
|
||||||
|
tags: typeof body.tags === 'string' ? body.tags : undefined,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ note }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
79
app/api/projects/[projectId]/route.ts
Normal file
79
app/api/projects/[projectId]/route.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createProjectService } from '@/lib/api/services';
|
||||||
|
import { UnauthorizedError } from '@/lib/errors';
|
||||||
|
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) {
|
||||||
|
return handleApiError(new UnauthorizedError());
|
||||||
|
}
|
||||||
|
const { projectId } = await params;
|
||||||
|
|
||||||
|
const service = createProjectService();
|
||||||
|
try {
|
||||||
|
const dashboard = service.getDashboard(user.id, Number(projectId));
|
||||||
|
return NextResponse.json(dashboard);
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) {
|
||||||
|
return handleApiError(new UnauthorizedError());
|
||||||
|
}
|
||||||
|
const { projectId } = await params;
|
||||||
|
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return jsonError(400, 'リクエスト本文が不正です');
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = createProjectService();
|
||||||
|
try {
|
||||||
|
const project = service.updateProject(user.id, Number(projectId), {
|
||||||
|
name: typeof body.name === 'string' ? body.name : undefined,
|
||||||
|
description:
|
||||||
|
typeof body.description === 'string' ? body.description : undefined,
|
||||||
|
status:
|
||||||
|
typeof body.status === 'string'
|
||||||
|
? (body.status as 'active' | 'on_hold' | 'completed' | 'archived')
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ project });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) {
|
||||||
|
return handleApiError(new UnauthorizedError());
|
||||||
|
}
|
||||||
|
const { projectId } = await params;
|
||||||
|
|
||||||
|
const service = createProjectService();
|
||||||
|
try {
|
||||||
|
service.deleteProject(user.id, Number(projectId));
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/api/projects/[projectId]/search/route.ts
Normal file
32
app/api/projects/[projectId]/search/route.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createSearchService } from '@/lib/api/services';
|
||||||
|
import { UnauthorizedError } from '@/lib/errors';
|
||||||
|
import { handleApiError } from '@/lib/api/handleError';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { projectId } = await params;
|
||||||
|
const q = request.nextUrl.searchParams.get('q') ?? '';
|
||||||
|
const type = (request.nextUrl.searchParams.get('type') ?? undefined) as
|
||||||
|
| string
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
const service = createSearchService();
|
||||||
|
try {
|
||||||
|
return NextResponse.json(
|
||||||
|
service.search(user.id, Number(projectId), {
|
||||||
|
q,
|
||||||
|
type: type as never,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createTodoService } from '@/lib/api/services';
|
||||||
|
import { UnauthorizedError } from '@/lib/errors';
|
||||||
|
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string; columnId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { columnId } = await params;
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return jsonError(400, 'リクエスト本文が不正です');
|
||||||
|
}
|
||||||
|
const service = createTodoService();
|
||||||
|
try {
|
||||||
|
const column = service.updateColumn(user.id, Number(columnId), {
|
||||||
|
name: typeof body.name === 'string' ? body.name : undefined,
|
||||||
|
orderIndex:
|
||||||
|
typeof body.orderIndex === 'number' ? body.orderIndex : undefined,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ column });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string; columnId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { columnId } = await params;
|
||||||
|
const service = createTodoService();
|
||||||
|
try {
|
||||||
|
service.deleteColumn(user.id, Number(columnId));
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/api/projects/[projectId]/todos/columns/route.ts
Normal file
51
app/api/projects/[projectId]/todos/columns/route.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createTodoService } from '@/lib/api/services';
|
||||||
|
import { UnauthorizedError } from '@/lib/errors';
|
||||||
|
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { projectId } = await params;
|
||||||
|
const service = createTodoService();
|
||||||
|
try {
|
||||||
|
return NextResponse.json({
|
||||||
|
columns: service.getColumns(user.id, Number(projectId)),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { projectId } = await params;
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return jsonError(400, 'リクエスト本文が不正です');
|
||||||
|
}
|
||||||
|
const service = createTodoService();
|
||||||
|
try {
|
||||||
|
const column = service.createColumn(
|
||||||
|
user.id,
|
||||||
|
Number(projectId),
|
||||||
|
String(body.name ?? ''),
|
||||||
|
typeof body.orderIndex === 'number' ? body.orderIndex : undefined
|
||||||
|
);
|
||||||
|
return NextResponse.json({ column }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
99
app/api/projects/[projectId]/todos/items/[itemId]/route.ts
Normal file
99
app/api/projects/[projectId]/todos/items/[itemId]/route.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createTodoService } from '@/lib/api/services';
|
||||||
|
import { UnauthorizedError } from '@/lib/errors';
|
||||||
|
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string; itemId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { itemId } = await params;
|
||||||
|
const service = createTodoService();
|
||||||
|
try {
|
||||||
|
const item = service.getItem(user.id, Number(itemId));
|
||||||
|
const attachments = service.getItemAttachments(user.id, Number(itemId));
|
||||||
|
return NextResponse.json({ item, attachments });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string; itemId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { itemId } = await params;
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return jsonError(400, 'リクエスト本文が不正です');
|
||||||
|
}
|
||||||
|
const service = createTodoService();
|
||||||
|
try {
|
||||||
|
// complete は専用フラグでトグル、それ以外は通常更新
|
||||||
|
if (body.toggleComplete === true) {
|
||||||
|
const item = service.toggleComplete(user.id, Number(itemId));
|
||||||
|
return NextResponse.json({ item });
|
||||||
|
}
|
||||||
|
if (body.columnId !== undefined && body.orderIndex !== undefined) {
|
||||||
|
const item = service.moveItem(
|
||||||
|
user.id,
|
||||||
|
Number(itemId),
|
||||||
|
Number(body.columnId),
|
||||||
|
Number(body.orderIndex)
|
||||||
|
);
|
||||||
|
return NextResponse.json({ item });
|
||||||
|
}
|
||||||
|
// nullable フィールドは absent(undefined=更新しない) と null(クリア) を区別する
|
||||||
|
const nullableString = (v: unknown): string | null | undefined =>
|
||||||
|
v === undefined ? undefined : v === null ? null : String(v);
|
||||||
|
const nullableNumber = (v: unknown): number | null | undefined =>
|
||||||
|
v === undefined ? undefined : v === null ? null : Number(v);
|
||||||
|
|
||||||
|
const item = service.updateItem(user.id, Number(itemId), {
|
||||||
|
title: typeof body.title === 'string' ? body.title : undefined,
|
||||||
|
description: nullableString(body.description),
|
||||||
|
assigneeId: nullableNumber(body.assigneeId),
|
||||||
|
priority:
|
||||||
|
typeof body.priority === 'string'
|
||||||
|
? (body.priority as 'low' | 'normal' | 'high')
|
||||||
|
: undefined,
|
||||||
|
startDate: nullableString(body.startDate),
|
||||||
|
dueDate: nullableString(body.dueDate),
|
||||||
|
tags: nullableString(body.tags),
|
||||||
|
milestoneId: nullableNumber(body.milestoneId),
|
||||||
|
fileIds: Array.isArray(body.fileIds)
|
||||||
|
? body.fileIds
|
||||||
|
.map((n) => Number(n))
|
||||||
|
.filter((n) => Number.isFinite(n) && n > 0)
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ item });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string; itemId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { itemId } = await params;
|
||||||
|
const service = createTodoService();
|
||||||
|
try {
|
||||||
|
service.deleteItem(user.id, Number(itemId));
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/api/projects/[projectId]/todos/items/reorder/route.ts
Normal file
46
app/api/projects/[projectId]/todos/items/reorder/route.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createTodoService } from '@/lib/api/services';
|
||||||
|
import { UnauthorizedError } from '@/lib/errors';
|
||||||
|
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* カラム内のアイテム順序を一括再採番する(同一カラムの並べ替え / 他カラムからの移動)。
|
||||||
|
* body: { columnId: number, itemIds: number[] } —— itemIds の順序で orderIndex 0..n-1 を割り当てる。
|
||||||
|
*/
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { projectId } = await params;
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return jsonError(400, 'リクエスト本文が不正です');
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnId = Number(body.columnId);
|
||||||
|
const itemIds = Array.isArray(body.itemIds)
|
||||||
|
? body.itemIds
|
||||||
|
.map((n) => Number(n))
|
||||||
|
.filter((n) => Number.isFinite(n) && n > 0)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const service = createTodoService();
|
||||||
|
try {
|
||||||
|
const items = service.reorderItems(
|
||||||
|
user.id,
|
||||||
|
Number(projectId),
|
||||||
|
columnId,
|
||||||
|
itemIds
|
||||||
|
);
|
||||||
|
return NextResponse.json({ items });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
69
app/api/projects/[projectId]/todos/items/route.ts
Normal file
69
app/api/projects/[projectId]/todos/items/route.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createTodoService } from '@/lib/api/services';
|
||||||
|
import { UnauthorizedError } from '@/lib/errors';
|
||||||
|
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { projectId } = await params;
|
||||||
|
const service = createTodoService();
|
||||||
|
try {
|
||||||
|
return NextResponse.json({
|
||||||
|
items: service.getItems(user.id, Number(projectId)),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { projectId } = await params;
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return jsonError(400, 'リクエスト本文が不正です');
|
||||||
|
}
|
||||||
|
const service = createTodoService();
|
||||||
|
try {
|
||||||
|
const fileIds = Array.isArray(body.fileIds)
|
||||||
|
? body.fileIds
|
||||||
|
.map((n) => Number(n))
|
||||||
|
.filter((n) => Number.isFinite(n) && n > 0)
|
||||||
|
: [];
|
||||||
|
const item = service.createItem(user.id, Number(projectId), {
|
||||||
|
title: String(body.title ?? ''),
|
||||||
|
columnId: Number(body.columnId ?? 0),
|
||||||
|
description:
|
||||||
|
typeof body.description === 'string' ? body.description : undefined,
|
||||||
|
assigneeId:
|
||||||
|
body.assigneeId === null || body.assigneeId === undefined
|
||||||
|
? null
|
||||||
|
: Number(body.assigneeId),
|
||||||
|
priority:
|
||||||
|
typeof body.priority === 'string'
|
||||||
|
? (body.priority as 'low' | 'normal' | 'high')
|
||||||
|
: undefined,
|
||||||
|
startDate:
|
||||||
|
typeof body.startDate === 'string' ? body.startDate : undefined,
|
||||||
|
dueDate: typeof body.dueDate === 'string' ? body.dueDate : null,
|
||||||
|
tags: typeof body.tags === 'string' ? body.tags : undefined,
|
||||||
|
fileIds,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ item }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
app/api/projects/route.ts
Normal file
43
app/api/projects/route.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createProjectService } from '@/lib/api/services';
|
||||||
|
import { UnauthorizedError } from '@/lib/errors';
|
||||||
|
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) {
|
||||||
|
return handleApiError(new UnauthorizedError());
|
||||||
|
}
|
||||||
|
const service = createProjectService();
|
||||||
|
const projects = service.getMyProjects(user.id);
|
||||||
|
return NextResponse.json({ projects });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) {
|
||||||
|
return handleApiError(new UnauthorizedError());
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return jsonError(400, 'リクエスト本文が不正です');
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = createProjectService();
|
||||||
|
try {
|
||||||
|
const project = service.createProject(user.id, {
|
||||||
|
name: String(body.name ?? ''),
|
||||||
|
description:
|
||||||
|
typeof body.description === 'string' ? body.description : undefined,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ project }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/api/users/me/route.ts
Normal file
54
app/api/users/me/route.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { AuthService } from '@/services/AuthService';
|
||||||
|
import { UserRepository } from '@/repositories/UserRepository';
|
||||||
|
import { getDb } from '@/lib/db/sqlite';
|
||||||
|
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { UnauthorizedError } from '@/lib/errors';
|
||||||
|
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||||
|
import type { Locale, Theme } from '@/lib/types';
|
||||||
|
import { PREF_MAX_AGE } from '@/lib/i18n/constants';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest) {
|
||||||
|
const currentUser = await getCurrentUser();
|
||||||
|
if (!currentUser) {
|
||||||
|
return handleApiError(new UnauthorizedError());
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return jsonError(400, 'リクエスト本文が不正です');
|
||||||
|
}
|
||||||
|
|
||||||
|
// theme/locale は生値を Service に渡し、バリデータで不正値を弾く(→ ValidationError → 400)
|
||||||
|
const authService = new AuthService(new UserRepository(getDb()));
|
||||||
|
try {
|
||||||
|
const updated = authService.updateProfile(currentUser.id, {
|
||||||
|
name: typeof body.name === 'string' ? body.name : undefined,
|
||||||
|
email: typeof body.email === 'string' ? body.email : undefined,
|
||||||
|
avatarUrl:
|
||||||
|
typeof body.avatarUrl === 'string' ? body.avatarUrl : undefined,
|
||||||
|
theme: typeof body.theme === 'string' ? (body.theme as Theme) : undefined,
|
||||||
|
locale:
|
||||||
|
typeof body.locale === 'string' ? (body.locale as Locale) : undefined,
|
||||||
|
});
|
||||||
|
const res = NextResponse.json({ user: toPublicUser(updated) });
|
||||||
|
// 保存結果の theme/locale をCookieに反映(SSRでレイアウトが読めるように)
|
||||||
|
res.cookies.set('theme', updated.theme, {
|
||||||
|
path: '/',
|
||||||
|
maxAge: PREF_MAX_AGE,
|
||||||
|
sameSite: 'lax',
|
||||||
|
});
|
||||||
|
res.cookies.set('locale', updated.locale, {
|
||||||
|
path: '/',
|
||||||
|
maxAge: PREF_MAX_AGE,
|
||||||
|
sameSite: 'lax',
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
109
app/dashboard/page.tsx
Normal file
109
app/dashboard/page.tsx
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createDashboardService } from '@/lib/api/services';
|
||||||
|
import { Header } from '@/components/layout/Header';
|
||||||
|
import { CreateProjectForm } from '@/components/project/CreateProjectForm';
|
||||||
|
import { DashboardWidget } from '@/components/project/DashboardWidget';
|
||||||
|
import { getLocale, translate } from '@/lib/i18n/server';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default async function DashboardPage() {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) {
|
||||||
|
redirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = createDashboardService();
|
||||||
|
const dashboard = service.getPersonalDashboard(user.id);
|
||||||
|
const locale = await getLocale();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
|
<Header user={toPublicUser(user)} />
|
||||||
|
<main className="mx-auto max-w-4xl space-y-6 p-6">
|
||||||
|
<h1 className="text-2xl font-bold">
|
||||||
|
{translate('page.dashboard', locale)}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<DashboardWidget
|
||||||
|
title={translate('dash.projects', locale)}
|
||||||
|
empty={translate('dash.empty', locale)}
|
||||||
|
>
|
||||||
|
{dashboard.projects.map((p) => (
|
||||||
|
<a
|
||||||
|
key={p.id}
|
||||||
|
href={`/projects/${p.id}`}
|
||||||
|
className="block rounded border px-3 py-2 text-sm hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
|
||||||
|
>
|
||||||
|
{p.name}({p.status})
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</DashboardWidget>
|
||||||
|
|
||||||
|
<DashboardWidget
|
||||||
|
title={`${translate('dash.unreadNotifications', locale)} (${dashboard.unreadNotificationCount})`}
|
||||||
|
empty={translate('dash.noUnread', locale)}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="/notifications"
|
||||||
|
className="text-sm text-blue-600 hover:underline dark:text-blue-400"
|
||||||
|
>
|
||||||
|
{translate('dash.openNotifications', locale)}
|
||||||
|
</a>
|
||||||
|
</DashboardWidget>
|
||||||
|
|
||||||
|
<DashboardWidget
|
||||||
|
title={translate('dash.incompleteTodos', locale)}
|
||||||
|
empty={translate('dash.empty', locale)}
|
||||||
|
>
|
||||||
|
{dashboard.incompleteTodos.map((t) => (
|
||||||
|
<p key={t.id} className="text-sm">
|
||||||
|
{t.title}
|
||||||
|
{t.dueDate
|
||||||
|
? `(${translate('dash.due', locale)}: ${t.dueDate})`
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</DashboardWidget>
|
||||||
|
|
||||||
|
<DashboardWidget
|
||||||
|
title={translate('dash.overdueTasks', locale)}
|
||||||
|
empty={translate('dash.empty', locale)}
|
||||||
|
>
|
||||||
|
{dashboard.overdueTasks.map((t) => (
|
||||||
|
<p key={t.id} className="text-sm text-red-600">
|
||||||
|
{t.title}({translate('dash.due', locale)}: {t.dueDate})
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</DashboardWidget>
|
||||||
|
|
||||||
|
<DashboardWidget
|
||||||
|
title={translate('dash.upcomingMeetings', locale)}
|
||||||
|
empty={translate('dash.empty', locale)}
|
||||||
|
>
|
||||||
|
{dashboard.upcomingMeetings.map((m) => (
|
||||||
|
<p key={m.id} className="text-sm">
|
||||||
|
{m.title}({m.startAt})
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</DashboardWidget>
|
||||||
|
|
||||||
|
<DashboardWidget
|
||||||
|
title={translate('dash.recentActivity', locale)}
|
||||||
|
empty={translate('dash.empty', locale)}
|
||||||
|
>
|
||||||
|
{dashboard.recentActivity.map((l) => (
|
||||||
|
<p key={l.id} className="text-sm">
|
||||||
|
{l.action}({l.targetType})
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</DashboardWidget>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CreateProjectForm />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,3 +1,12 @@
|
|||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* ルート要素の最低限のダーク/ライト背景フォールバック。
|
||||||
|
各コンポーネントは dark: ユーティリティで個別に調整している。 */
|
||||||
|
html {
|
||||||
|
color-scheme: light dark;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-gray-50 text-gray-800 dark:bg-gray-900 dark:text-gray-100;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,20 +1,61 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata, Viewport } from 'next';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
import { I18nProvider } from '@/lib/i18n/I18nProvider';
|
||||||
|
import { ServiceWorkerRegister } from '@/components/pwa/ServiceWorkerRegister';
|
||||||
|
import type { Locale, Theme } from '@/lib/types';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'シンプルグループウェア',
|
title: 'Groupware',
|
||||||
description:
|
description: 'Project-based team collaboration tool',
|
||||||
'プロジェクト単位で情報共有・タスク管理を行えるチームコラボレーションツール',
|
applicationName: 'Groupware',
|
||||||
|
appleWebApp: {
|
||||||
|
capable: true,
|
||||||
|
statusBarStyle: 'default',
|
||||||
|
title: 'Groupware',
|
||||||
|
},
|
||||||
|
icons: {
|
||||||
|
icon: '/icon.svg',
|
||||||
|
apple: '/icon-192.png',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export const viewport: Viewport = {
|
||||||
|
width: 'device-width',
|
||||||
|
initialScale: 1,
|
||||||
|
viewportFit: 'cover',
|
||||||
|
themeColor: '#2563eb',
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveTheme(v: string | undefined): Theme {
|
||||||
|
return v === 'light' ? 'light' : 'dark'; // 既定は dark
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLocale(v: string | undefined): Locale {
|
||||||
|
return v === 'ja' ? 'ja' : 'en'; // 既定は en
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const theme = resolveTheme(cookieStore.get('theme')?.value);
|
||||||
|
const locale = resolveLocale(cookieStore.get('locale')?.value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="ja">
|
<html
|
||||||
<body>{children}</body>
|
lang={locale}
|
||||||
|
className={theme === 'dark' ? 'dark' : ''}
|
||||||
|
style={{ colorScheme: theme }}
|
||||||
|
>
|
||||||
|
<body>
|
||||||
|
<I18nProvider initialLocale={locale} initialTheme={theme}>
|
||||||
|
{children}
|
||||||
|
</I18nProvider>
|
||||||
|
<ServiceWorkerRegister />
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
137
app/login/page.tsx
Normal file
137
app/login/page.tsx
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useI18n } from '@/lib/i18n/I18nProvider';
|
||||||
|
|
||||||
|
type Mode = 'login' | 'register';
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [mode, setMode] = useState<Mode>('login');
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const endpoint =
|
||||||
|
mode === 'login' ? '/api/auth/login' : '/api/auth/register';
|
||||||
|
const payload =
|
||||||
|
mode === 'login' ? { email, password } : { name, email, password };
|
||||||
|
|
||||||
|
const res = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
router.push('/dashboard');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json().catch(() => null)) as {
|
||||||
|
error?: { message?: string };
|
||||||
|
} | null;
|
||||||
|
setError(data?.error?.message ?? t('auth.failed'));
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen flex-col items-center justify-center bg-gray-50 p-8 dark:bg-gray-900">
|
||||||
|
<div className="w-full max-w-sm rounded-lg border bg-white p-8 shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||||
|
<h1 className="text-2xl font-bold">
|
||||||
|
{mode === 'login' ? t('auth.login') : t('auth.register')}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<form className="mt-6 space-y-4" onSubmit={onSubmit}>
|
||||||
|
{mode === 'register' && (
|
||||||
|
<div>
|
||||||
|
<label htmlFor="name" className="block text-sm font-medium">
|
||||||
|
{t('auth.displayName')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded border px-3 py-2 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium">
|
||||||
|
{t('auth.email')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded border px-3 py-2 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium">
|
||||||
|
{t('auth.password')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete={
|
||||||
|
mode === 'login' ? 'current-password' : 'new-password'
|
||||||
|
}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded border px-3 py-2 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-600" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading
|
||||||
|
? t('auth.processing')
|
||||||
|
: mode === 'login'
|
||||||
|
? t('auth.loginButton')
|
||||||
|
: t('auth.registerButton')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mt-4 w-full text-center text-sm text-blue-600 hover:underline dark:text-blue-400"
|
||||||
|
onClick={() => {
|
||||||
|
setMode(mode === 'login' ? 'register' : 'login');
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mode === 'login' ? t('auth.registerHere') : t('auth.backToLogin')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
app/manifest.ts
Normal file
51
app/manifest.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import type { MetadataRoute } from 'next';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PWA マニフェスト。Next.js が /manifest.webmanifest を生成し、
|
||||||
|
* <link rel="manifest"> を自動で <head> に出力する。
|
||||||
|
*/
|
||||||
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
|
return {
|
||||||
|
name: 'Groupware',
|
||||||
|
short_name: 'Groupware',
|
||||||
|
description: 'Project-based team collaboration tool',
|
||||||
|
start_url: '/',
|
||||||
|
scope: '/',
|
||||||
|
display: 'standalone',
|
||||||
|
orientation: 'any',
|
||||||
|
background_color: '#111827',
|
||||||
|
theme_color: '#2563eb',
|
||||||
|
lang: 'en',
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: '/icon-192.png',
|
||||||
|
sizes: '192x192',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'any',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/icon-192.png',
|
||||||
|
sizes: '192x192',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'maskable',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/icon-512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'any',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/icon-512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'maskable',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/icon.svg',
|
||||||
|
sizes: 'any',
|
||||||
|
type: 'image/svg+xml',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
27
app/notifications/page.tsx
Normal file
27
app/notifications/page.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createNotificationService } from '@/lib/api/services';
|
||||||
|
import { Header } from '@/components/layout/Header';
|
||||||
|
import { NotificationList } from '@/components/notifications/NotificationList';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default async function NotificationsPage() {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) {
|
||||||
|
redirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = createNotificationService();
|
||||||
|
const { items } = service.listUnread(user.id, 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
|
<Header user={toPublicUser(user)} />
|
||||||
|
<main className="mx-auto max-w-2xl space-y-6 p-6">
|
||||||
|
<h1 className="text-2xl font-bold">通知</h1>
|
||||||
|
<NotificationList notifications={items} />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
app/page.tsx
18
app/page.tsx
@ -1,10 +1,12 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
const router = useRouter();
|
||||||
<main className="flex min-h-screen flex-col items-center justify-center p-8">
|
useEffect(() => {
|
||||||
<h1 className="text-3xl font-bold">シンプルグループウェア</h1>
|
router.replace('/dashboard');
|
||||||
<p className="mt-4 text-gray-600">
|
}, [router]);
|
||||||
プロジェクト単位で情報共有・タスク管理を行えるチームコラボレーションツール
|
return null;
|
||||||
</p>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
188
app/profile/page.tsx
Normal file
188
app/profile/page.tsx
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, type FormEvent } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import type { PublicUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { useI18n } from '@/lib/i18n/I18nProvider';
|
||||||
|
|
||||||
|
export default function ProfilePage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { t, locale, theme, setLocale, setTheme } = useI18n();
|
||||||
|
const [user, setUser] = useState<PublicUser | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [avatarUrl, setAvatarUrl] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/auth/me')
|
||||||
|
.then((res) => (res.ok ? res.json() : Promise.reject(res)))
|
||||||
|
.then((data: { user: PublicUser }) => {
|
||||||
|
setUser(data.user);
|
||||||
|
setName(data.user.name);
|
||||||
|
setEmail(data.user.email);
|
||||||
|
setAvatarUrl(data.user.avatarUrl ?? '');
|
||||||
|
})
|
||||||
|
.catch(() => router.push('/login'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setSaved(false);
|
||||||
|
const res = await fetch('/api/users/me', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, email, avatarUrl }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = (await res.json()) as { user: PublicUser };
|
||||||
|
setUser(data.user);
|
||||||
|
setSaved(true);
|
||||||
|
} else {
|
||||||
|
const data = (await res.json().catch(() => null)) as {
|
||||||
|
error?: { message?: string };
|
||||||
|
} | null;
|
||||||
|
setError(data?.error?.message ?? t('auth.failed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onLogout() {
|
||||||
|
await fetch('/api/auth/logout', { method: 'POST' });
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
|
{t('common.loading')}
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-gray-50 p-8 dark:bg-gray-900">
|
||||||
|
<div className="mx-auto max-w-md rounded-lg border bg-white p-8 shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">{t('profile.title')}</h1>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onLogout}
|
||||||
|
className="text-sm text-blue-600 hover:underline dark:text-blue-400"
|
||||||
|
>
|
||||||
|
{t('header.logout')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="mt-6 space-y-4" onSubmit={onSubmit}>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="name" className="block text-sm font-medium">
|
||||||
|
{t('profile.displayName')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded border px-3 py-2 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium">
|
||||||
|
{t('profile.email')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded border px-3 py-2 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="avatarUrl" className="block text-sm font-medium">
|
||||||
|
{t('profile.avatarUrl')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="avatarUrl"
|
||||||
|
type="url"
|
||||||
|
value={avatarUrl}
|
||||||
|
onChange={(e) => setAvatarUrl(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded border px-3 py-2 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-600" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{saved && (
|
||||||
|
<p className="text-sm text-green-600">{t('profile.saved')}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
{t('common.save')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 space-y-3 border-t pt-4 dark:border-gray-700">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="theme-select" className="block text-sm font-medium">
|
||||||
|
{t('profile.theme')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="theme-select"
|
||||||
|
value={theme}
|
||||||
|
onChange={(e) =>
|
||||||
|
void setTheme(e.target.value as 'dark' | 'light')
|
||||||
|
}
|
||||||
|
className="mt-1 w-full rounded border px-3 py-2 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
data-testid="profile-theme-select"
|
||||||
|
>
|
||||||
|
<option value="dark">{t('theme.dark')}</option>
|
||||||
|
<option value="light">{t('theme.light')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="locale-select"
|
||||||
|
className="block text-sm font-medium"
|
||||||
|
>
|
||||||
|
{t('profile.language')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="locale-select"
|
||||||
|
value={locale}
|
||||||
|
onChange={(e) => setLocale(e.target.value as 'en' | 'ja')}
|
||||||
|
className="mt-1 w-full rounded border px-3 py-2 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
data-testid="profile-locale-select"
|
||||||
|
>
|
||||||
|
<option value="en">{t('language.english')}</option>
|
||||||
|
<option value="ja">{t('language.japanese')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push('/dashboard')}
|
||||||
|
className="mt-4 w-full text-center text-sm text-blue-600 hover:underline dark:text-blue-400"
|
||||||
|
>
|
||||||
|
{t('profile.backToDashboard')}
|
||||||
|
</button>
|
||||||
|
<p className="mt-2 text-center text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{t('profile.role')}: {user?.role}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
app/projects/[projectId]/activity/page.tsx
Normal file
85
app/projects/[projectId]/activity/page.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import {
|
||||||
|
createProjectService,
|
||||||
|
createActivityLogService,
|
||||||
|
} from '@/lib/api/services';
|
||||||
|
import { Header } from '@/components/layout/Header';
|
||||||
|
import { ProjectNav } from '@/components/layout/ProjectNav';
|
||||||
|
import { ForbiddenError, NotFoundError } from '@/lib/errors';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
const ACTION_LABELS: Record<string, string> = {
|
||||||
|
todo_created: 'ToDo作成',
|
||||||
|
todo_updated: 'ToDo更新',
|
||||||
|
todo_completed: 'ToDo完了',
|
||||||
|
file_uploaded: 'ファイルアップロード',
|
||||||
|
board_posted: '掲示板投稿',
|
||||||
|
comment_added: 'コメント追加',
|
||||||
|
note_created: 'メモ作成',
|
||||||
|
note_updated: 'メモ更新',
|
||||||
|
meeting_created: 'ミーティング作成',
|
||||||
|
member_added: 'メンバー追加',
|
||||||
|
milestone_updated: 'マイルストーン更新',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function ProjectActivityPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ projectId: string }>;
|
||||||
|
}) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) {
|
||||||
|
redirect('/login');
|
||||||
|
}
|
||||||
|
const { projectId } = await params;
|
||||||
|
|
||||||
|
const projectService = createProjectService();
|
||||||
|
let project;
|
||||||
|
try {
|
||||||
|
project = projectService.getProject(user.id, Number(projectId));
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
|
||||||
|
redirect('/dashboard');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activityService = createActivityLogService();
|
||||||
|
const { items } = activityService.listByProject(project.id, 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
|
<Header user={toPublicUser(user)} />
|
||||||
|
<ProjectNav projectId={project.id} active="activity" />
|
||||||
|
<main className="mx-auto max-w-3xl space-y-6 p-6">
|
||||||
|
<h1 className="text-2xl font-bold">アクティビティログ</h1>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-400 dark:text-gray-500">
|
||||||
|
アクティビティはありません。
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y rounded-lg border bg-white dark:bg-gray-800 shadow-sm">
|
||||||
|
{items.map((log) => (
|
||||||
|
<li key={log.id} className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="rounded bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs">
|
||||||
|
{ACTION_LABELS[log.action] ?? log.action}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
{log.createdAt}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm text-gray-700 dark:text-gray-200">
|
||||||
|
{log.targetType}
|
||||||
|
{log.targetId !== null ? ` #${log.targetId}` : ''}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
app/projects/[projectId]/board/[threadId]/page.tsx
Normal file
116
app/projects/[projectId]/board/[threadId]/page.tsx
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createBoardService } from '@/lib/api/services';
|
||||||
|
import { Header } from '@/components/layout/Header';
|
||||||
|
import { ProjectNav } from '@/components/layout/ProjectNav';
|
||||||
|
import { MarkdownBody } from '@/components/board/MarkdownBody';
|
||||||
|
import { CommentForm } from '@/components/board/CommentForm';
|
||||||
|
import { AttachmentList } from '@/components/files/AttachmentList';
|
||||||
|
import type { AttachmentView } from '@/lib/types';
|
||||||
|
import { ForbiddenError, NotFoundError } from '@/lib/errors';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default async function ThreadDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ projectId: string; threadId: string }>;
|
||||||
|
}) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) redirect('/login');
|
||||||
|
const { projectId, threadId } = await params;
|
||||||
|
|
||||||
|
const boardService = createBoardService();
|
||||||
|
let thread;
|
||||||
|
let comments;
|
||||||
|
let attachments: { thread: AttachmentView[]; comments: AttachmentView[] };
|
||||||
|
try {
|
||||||
|
thread = boardService.getThread(user.id, Number(threadId));
|
||||||
|
comments = boardService.listComments(user.id, Number(threadId));
|
||||||
|
attachments = boardService.getAttachments(
|
||||||
|
user.id,
|
||||||
|
thread.id,
|
||||||
|
comments.items.map((c) => c.id)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
|
||||||
|
redirect(`/projects/${projectId}/board`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// コメントIDごとに添付をグループ化
|
||||||
|
const commentAttachments = new Map(
|
||||||
|
attachments.comments.map((a) => [
|
||||||
|
a.targetId,
|
||||||
|
[] as typeof attachments.comments,
|
||||||
|
])
|
||||||
|
);
|
||||||
|
for (const a of attachments.comments) {
|
||||||
|
const list = commentAttachments.get(a.targetId);
|
||||||
|
if (list) list.push(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
|
<Header user={toPublicUser(user)} />
|
||||||
|
<ProjectNav projectId={Number(projectId)} active="board" />
|
||||||
|
<main className="mx-auto max-w-3xl space-y-6 p-6">
|
||||||
|
<a
|
||||||
|
href={`/projects/${projectId}/board`}
|
||||||
|
className="text-sm text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
← 掲示板一覧へ
|
||||||
|
</a>
|
||||||
|
<div className="rounded-lg border bg-white dark:bg-gray-800 p-6 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">{thread.title}</h1>
|
||||||
|
{thread.category && (
|
||||||
|
<span className="rounded bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs">
|
||||||
|
{thread.category}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<MarkdownBody bodyMd={thread.bodyMd} />
|
||||||
|
</div>
|
||||||
|
{attachments.thread.length > 0 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<p className="mb-1 text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
添付ファイル
|
||||||
|
</p>
|
||||||
|
<AttachmentList attachments={attachments.thread} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="mt-4 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
投稿: {thread.createdAt} / 更新: {thread.updatedAt}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h2 className="text-lg font-bold">コメント ({comments.total})</h2>
|
||||||
|
{comments.items.map((comment) => (
|
||||||
|
<div
|
||||||
|
key={comment.id}
|
||||||
|
className="rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm"
|
||||||
|
>
|
||||||
|
<MarkdownBody bodyMd={comment.bodyMd} />
|
||||||
|
{commentAttachments.has(comment.id) &&
|
||||||
|
commentAttachments.get(comment.id)!.length > 0 && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<AttachmentList
|
||||||
|
attachments={commentAttachments.get(comment.id)!}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="mt-2 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
{comment.createdAt}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<CommentForm projectId={Number(projectId)} threadId={thread.id} />
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
app/projects/[projectId]/board/page.tsx
Normal file
81
app/projects/[projectId]/board/page.tsx
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createBoardService, createProjectService } from '@/lib/api/services';
|
||||||
|
import { Header } from '@/components/layout/Header';
|
||||||
|
import { ProjectNav } from '@/components/layout/ProjectNav';
|
||||||
|
import { ThreadForm } from '@/components/board/ThreadForm';
|
||||||
|
import { ForbiddenError, NotFoundError } from '@/lib/errors';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default async function BoardPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ projectId: string }>;
|
||||||
|
searchParams: Promise<{ q?: string; page?: string }>;
|
||||||
|
}) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) redirect('/login');
|
||||||
|
const { projectId } = await params;
|
||||||
|
const { q, page } = await searchParams;
|
||||||
|
|
||||||
|
const projectService = createProjectService();
|
||||||
|
let project: Awaited<ReturnType<typeof projectService.getProject>>;
|
||||||
|
try {
|
||||||
|
project = projectService.getProject(user.id, Number(projectId));
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
|
||||||
|
redirect('/dashboard');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const boardService = createBoardService();
|
||||||
|
const { items } = boardService.listThreads(user.id, project.id, {
|
||||||
|
page: Number(page ?? '1') || 1,
|
||||||
|
search: q,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
|
<Header user={toPublicUser(user)} />
|
||||||
|
<ProjectNav projectId={project.id} active="board" />
|
||||||
|
<main className="mx-auto max-w-3xl space-y-6 p-6">
|
||||||
|
<h1 className="text-2xl font-bold">掲示板</h1>
|
||||||
|
<ThreadForm projectId={project.id} />
|
||||||
|
<section className="space-y-3">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-400 dark:text-gray-500">
|
||||||
|
スレッドはありません。
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
items.map((thread) => (
|
||||||
|
<a
|
||||||
|
key={thread.id}
|
||||||
|
href={`/projects/${project.id}/board/${thread.id}`}
|
||||||
|
className="block rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm hover:shadow-md"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="font-semibold text-gray-800 dark:text-gray-100">
|
||||||
|
{thread.isPinned === 1 && '📌 '}
|
||||||
|
{thread.isImportant === 1 && '❗ '}
|
||||||
|
{thread.title}
|
||||||
|
</h3>
|
||||||
|
{thread.category && (
|
||||||
|
<span className="rounded bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs">
|
||||||
|
{thread.category}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
{thread.createdAt}
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
app/projects/[projectId]/calendar/page.tsx
Normal file
82
app/projects/[projectId]/calendar/page.tsx
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import {
|
||||||
|
createScheduleService,
|
||||||
|
createProjectService,
|
||||||
|
} from '@/lib/api/services';
|
||||||
|
import { Header } from '@/components/layout/Header';
|
||||||
|
import { ProjectNav } from '@/components/layout/ProjectNav';
|
||||||
|
import { CalendarEventForm } from '@/components/calendar/CalendarEventForm';
|
||||||
|
import { CalendarView } from '@/components/calendar/CalendarView';
|
||||||
|
import {
|
||||||
|
rangeForView,
|
||||||
|
toISODate,
|
||||||
|
parseISODate,
|
||||||
|
type CalendarViewMode,
|
||||||
|
} from '@/lib/calendar/grid';
|
||||||
|
import { ForbiddenError, NotFoundError } from '@/lib/errors';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
const VALID_VIEWS: CalendarViewMode[] = ['month', 'week', 'day'];
|
||||||
|
|
||||||
|
function isCalendarView(v: unknown): v is CalendarViewMode {
|
||||||
|
return typeof v === 'string' && (VALID_VIEWS as string[]).includes(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CalendarPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ projectId: string }>;
|
||||||
|
searchParams: Promise<{ view?: string; date?: string }>;
|
||||||
|
}) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) redirect('/login');
|
||||||
|
const { projectId } = await params;
|
||||||
|
const { view, date } = await searchParams;
|
||||||
|
|
||||||
|
const projectService = createProjectService();
|
||||||
|
let project: Awaited<ReturnType<typeof projectService.getProject>>;
|
||||||
|
try {
|
||||||
|
project = projectService.getProject(user.id, Number(projectId));
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
|
||||||
|
redirect('/dashboard');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表示モードと基準日を解決(不正値はデフォルトへフォールバック)
|
||||||
|
const resolvedView: CalendarViewMode = isCalendarView(view) ? view : 'month';
|
||||||
|
const today = new Date();
|
||||||
|
const anchor =
|
||||||
|
date && /^\d{4}-\d{2}-\d{2}$/.test(date) ? parseISODate(date) : today;
|
||||||
|
|
||||||
|
// ビューに応じた取得範囲を計算しイベントを取得。
|
||||||
|
// アクセス権は getProject で参加確認済みなのでここでは再チェックしない。
|
||||||
|
const range = rangeForView(resolvedView, anchor);
|
||||||
|
const scheduleService = createScheduleService();
|
||||||
|
const events = scheduleService.getCalendarEvents(user.id, project.id, range);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
|
<Header user={toPublicUser(user)} />
|
||||||
|
<ProjectNav projectId={project.id} active="calendar" />
|
||||||
|
<main className="mx-auto max-w-6xl space-y-6 p-6">
|
||||||
|
<h1 className="text-2xl font-bold">カレンダー</h1>
|
||||||
|
<CalendarEventForm
|
||||||
|
projectId={project.id}
|
||||||
|
defaultDate={toISODate(anchor)}
|
||||||
|
/>
|
||||||
|
<CalendarView
|
||||||
|
projectId={project.id}
|
||||||
|
events={events}
|
||||||
|
view={resolvedView}
|
||||||
|
anchorDate={toISODate(anchor)}
|
||||||
|
todayKey={toISODate(today)}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
app/projects/[projectId]/chat/page.tsx
Normal file
41
app/projects/[projectId]/chat/page.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createProjectService } from '@/lib/api/services';
|
||||||
|
import { Header } from '@/components/layout/Header';
|
||||||
|
import { ProjectNav } from '@/components/layout/ProjectNav';
|
||||||
|
import { ChatWindow } from '@/components/chat/ChatWindow';
|
||||||
|
import { ForbiddenError, NotFoundError } from '@/lib/errors';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default async function ChatPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ projectId: string }>;
|
||||||
|
}) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) redirect('/login');
|
||||||
|
const { projectId } = await params;
|
||||||
|
|
||||||
|
const projectService = createProjectService();
|
||||||
|
let project: Awaited<ReturnType<typeof projectService.getProject>>;
|
||||||
|
try {
|
||||||
|
project = projectService.getProject(user.id, Number(projectId));
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
|
||||||
|
redirect('/dashboard');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
|
<Header user={toPublicUser(user)} />
|
||||||
|
<ProjectNav projectId={project.id} active="chat" />
|
||||||
|
<main className="mx-auto max-w-3xl space-y-4 p-6">
|
||||||
|
<h1 className="text-2xl font-bold">チャット</h1>
|
||||||
|
<ChatWindow projectId={project.id} userName={user.name} />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
app/projects/[projectId]/files/page.tsx
Normal file
52
app/projects/[projectId]/files/page.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import {
|
||||||
|
createFileStorageService,
|
||||||
|
createProjectService,
|
||||||
|
} from '@/lib/api/services';
|
||||||
|
import { Header } from '@/components/layout/Header';
|
||||||
|
import { ProjectNav } from '@/components/layout/ProjectNav';
|
||||||
|
import { Uploader } from '@/components/files/Uploader';
|
||||||
|
import { FileList } from '@/components/files/FileList';
|
||||||
|
import { ForbiddenError, NotFoundError } from '@/lib/errors';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default async function FilesPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ projectId: string }>;
|
||||||
|
}) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) redirect('/login');
|
||||||
|
const { projectId } = await params;
|
||||||
|
|
||||||
|
const projectService = createProjectService();
|
||||||
|
let project: Awaited<ReturnType<typeof projectService.getProject>>;
|
||||||
|
try {
|
||||||
|
project = projectService.getProject(user.id, Number(projectId));
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
|
||||||
|
redirect('/dashboard');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileService = createFileStorageService();
|
||||||
|
const { items } = fileService.listFiles(user.id, project.id);
|
||||||
|
const canDelete =
|
||||||
|
projectService.getMemberRole(user.id, project.id) === 'admin' ||
|
||||||
|
items.some((f) => f.uploaderId === user.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
|
<Header user={toPublicUser(user)} />
|
||||||
|
<ProjectNav projectId={project.id} active="files" />
|
||||||
|
<main className="mx-auto max-w-4xl space-y-6 p-6">
|
||||||
|
<h1 className="text-2xl font-bold">ファイル</h1>
|
||||||
|
<Uploader projectId={project.id} />
|
||||||
|
<FileList files={items} canDelete={canDelete} />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
app/projects/[projectId]/meetings/page.tsx
Normal file
80
app/projects/[projectId]/meetings/page.tsx
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createMeetingService, createProjectService } from '@/lib/api/services';
|
||||||
|
import { Header } from '@/components/layout/Header';
|
||||||
|
import { ProjectNav } from '@/components/layout/ProjectNav';
|
||||||
|
import { MeetingForm } from '@/components/meetings/MeetingForm';
|
||||||
|
import { ForbiddenError, NotFoundError } from '@/lib/errors';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default async function MeetingsPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ projectId: string }>;
|
||||||
|
}) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) redirect('/login');
|
||||||
|
const { projectId } = await params;
|
||||||
|
|
||||||
|
const projectService = createProjectService();
|
||||||
|
let project: Awaited<ReturnType<typeof projectService.getProject>>;
|
||||||
|
try {
|
||||||
|
project = projectService.getProject(user.id, Number(projectId));
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
|
||||||
|
redirect('/dashboard');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const meetingService = createMeetingService();
|
||||||
|
const meetings = meetingService.getMeetings(user.id, project.id);
|
||||||
|
const members = projectService.getMembers(user.id, project.id).map((m) => ({
|
||||||
|
userId: m.userId,
|
||||||
|
name: m.user.name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
|
<Header user={toPublicUser(user)} />
|
||||||
|
<ProjectNav projectId={project.id} active="meetings" />
|
||||||
|
<main className="mx-auto max-w-3xl space-y-6 p-6">
|
||||||
|
<h1 className="text-2xl font-bold">ミーティング</h1>
|
||||||
|
<MeetingForm projectId={project.id} members={members} />
|
||||||
|
<section className="space-y-3">
|
||||||
|
{meetings.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-400 dark:text-gray-500">
|
||||||
|
ミーティングはありません。
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
meetings.map((m) => (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
className="rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm"
|
||||||
|
data-testid={`meeting-${m.id}`}
|
||||||
|
>
|
||||||
|
<h3 className="font-semibold text-gray-800 dark:text-gray-100">
|
||||||
|
{m.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{m.startAt} 〜 {m.endAt}
|
||||||
|
</p>
|
||||||
|
{m.location && (
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
場所: {m.location}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{m.minutesMd && (
|
||||||
|
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
議事録あり
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
app/projects/[projectId]/members/page.tsx
Normal file
88
app/projects/[projectId]/members/page.tsx
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createProjectService } from '@/lib/api/services';
|
||||||
|
import { Header } from '@/components/layout/Header';
|
||||||
|
import { ProjectNav } from '@/components/layout/ProjectNav';
|
||||||
|
import { AddMemberForm } from '@/components/project/AddMemberForm';
|
||||||
|
import { RemoveMemberButton } from '@/components/project/RemoveMemberButton';
|
||||||
|
import { ForbiddenError, NotFoundError } from '@/lib/errors';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
const ROLE_LABEL = { admin: '管理者', member: 'メンバー', guest: 'ゲスト' };
|
||||||
|
|
||||||
|
export default async function ProjectMembersPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ projectId: string }>;
|
||||||
|
}) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) {
|
||||||
|
redirect('/login');
|
||||||
|
}
|
||||||
|
const { projectId } = await params;
|
||||||
|
|
||||||
|
const service = createProjectService();
|
||||||
|
let project: Awaited<ReturnType<typeof service.getProject>>;
|
||||||
|
let members: Awaited<ReturnType<typeof service.getMembers>>;
|
||||||
|
let canManage: boolean;
|
||||||
|
try {
|
||||||
|
project = service.getProject(user.id, Number(projectId));
|
||||||
|
members = service.getMembers(user.id, Number(projectId));
|
||||||
|
canManage = service.getMemberRole(user.id, Number(projectId)) === 'admin';
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
|
||||||
|
redirect('/dashboard');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
|
<Header user={toPublicUser(user)} />
|
||||||
|
<ProjectNav projectId={project.id} active="members" />
|
||||||
|
<main className="mx-auto max-w-3xl space-y-6 p-6">
|
||||||
|
<h1 className="text-2xl font-bold">メンバー管理</h1>
|
||||||
|
|
||||||
|
{canManage && <AddMemberForm projectId={project.id} />}
|
||||||
|
|
||||||
|
<section className="rounded-lg border bg-white dark:bg-gray-800 shadow-sm">
|
||||||
|
<ul className="divide-y">
|
||||||
|
{members.map((member) => (
|
||||||
|
<li
|
||||||
|
key={member.id}
|
||||||
|
className="flex items-center justify-between p-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-800 dark:text-gray-100">
|
||||||
|
{member.user.name}
|
||||||
|
{member.userId === user.id && (
|
||||||
|
<span className="ml-2 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
(あなた)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{member.user.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{ROLE_LABEL[member.role] ?? member.role}
|
||||||
|
</span>
|
||||||
|
{canManage && member.userId !== user.id && (
|
||||||
|
<RemoveMemberButton
|
||||||
|
projectId={project.id}
|
||||||
|
userId={member.userId}
|
||||||
|
label="削除"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
app/projects/[projectId]/milestones/page.tsx
Normal file
97
app/projects/[projectId]/milestones/page.tsx
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import {
|
||||||
|
createScheduleService,
|
||||||
|
createProjectService,
|
||||||
|
} from '@/lib/api/services';
|
||||||
|
import { Header } from '@/components/layout/Header';
|
||||||
|
import { ProjectNav } from '@/components/layout/ProjectNav';
|
||||||
|
import { MilestoneForm } from '@/components/calendar/MilestoneForm';
|
||||||
|
import { ForbiddenError, NotFoundError } from '@/lib/errors';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
function progressColor(progress: number): string {
|
||||||
|
if (progress >= 67) return 'bg-green-500';
|
||||||
|
if (progress >= 34) return 'bg-yellow-500';
|
||||||
|
return 'bg-red-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function MilestonesPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ projectId: string }>;
|
||||||
|
}) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) redirect('/login');
|
||||||
|
const { projectId } = await params;
|
||||||
|
|
||||||
|
const projectService = createProjectService();
|
||||||
|
let project: Awaited<ReturnType<typeof projectService.getProject>>;
|
||||||
|
try {
|
||||||
|
project = projectService.getProject(user.id, Number(projectId));
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
|
||||||
|
redirect('/dashboard');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleService = createScheduleService();
|
||||||
|
const milestones = scheduleService.getMilestones(user.id, project.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
|
<Header user={toPublicUser(user)} />
|
||||||
|
<ProjectNav projectId={project.id} active="milestones" />
|
||||||
|
<main className="mx-auto max-w-3xl space-y-6 p-6">
|
||||||
|
<h1 className="text-2xl font-bold">マイルストーン</h1>
|
||||||
|
<MilestoneForm projectId={project.id} />
|
||||||
|
<section className="space-y-3">
|
||||||
|
{milestones.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-400 dark:text-gray-500">
|
||||||
|
マイルストーンはありません。
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
milestones.map((m) => (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
className="rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm"
|
||||||
|
data-testid={`milestone-${m.id}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="font-semibold text-gray-800 dark:text-gray-100">
|
||||||
|
{m.title}
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{m.status}
|
||||||
|
{m.dueDate ? ` / 期限: ${m.dueDate}` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{m.description && (
|
||||||
|
<p className="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{m.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span>進捗</span>
|
||||||
|
<span data-testid={`milestone-progress-${m.id}`}>
|
||||||
|
{m.progress}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 h-2 w-full rounded bg-gray-200 dark:bg-gray-700">
|
||||||
|
<div
|
||||||
|
className={`h-2 rounded ${progressColor(m.progress)}`}
|
||||||
|
style={{ width: `${m.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
app/projects/[projectId]/notes/[noteId]/page.tsx
Normal file
65
app/projects/[projectId]/notes/[noteId]/page.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createNoteService } from '@/lib/api/services';
|
||||||
|
import { Header } from '@/components/layout/Header';
|
||||||
|
import { ProjectNav } from '@/components/layout/ProjectNav';
|
||||||
|
import { MarkdownBody } from '@/components/board/MarkdownBody';
|
||||||
|
import { NoteEditor } from '@/components/notes/NoteEditor';
|
||||||
|
import { ForbiddenError, NotFoundError } from '@/lib/errors';
|
||||||
|
import type { ProjectNote } from '@/lib/types';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default async function NoteDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ projectId: string; noteId: string }>;
|
||||||
|
}) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) redirect('/login');
|
||||||
|
const { projectId, noteId } = await params;
|
||||||
|
|
||||||
|
const noteService = createNoteService();
|
||||||
|
let note: ProjectNote;
|
||||||
|
try {
|
||||||
|
note = noteService.getNote(user.id, Number(noteId));
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
|
||||||
|
redirect(`/projects/${projectId}/notes`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
|
<Header user={toPublicUser(user)} />
|
||||||
|
<ProjectNav projectId={Number(projectId)} active="notes" />
|
||||||
|
<main className="mx-auto max-w-3xl space-y-6 p-6">
|
||||||
|
<a
|
||||||
|
href={`/projects/${projectId}/notes`}
|
||||||
|
className="text-sm text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
← メモ一覧へ
|
||||||
|
</a>
|
||||||
|
<div className="rounded-lg border bg-white dark:bg-gray-800 p-6 shadow-sm">
|
||||||
|
<h1 className="text-2xl font-bold">
|
||||||
|
{note.isPinned === 1 && '📌 '}
|
||||||
|
{note.title}
|
||||||
|
</h1>
|
||||||
|
{note.tags && (
|
||||||
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{note.tags}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-4">
|
||||||
|
<MarkdownBody bodyMd={note.bodyMd} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
作成: {note.createdAt} / 更新: {note.updatedAt}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<NoteEditor projectId={Number(projectId)} note={note} />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
app/projects/[projectId]/notes/page.tsx
Normal file
80
app/projects/[projectId]/notes/page.tsx
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createNoteService, createProjectService } from '@/lib/api/services';
|
||||||
|
import { Header } from '@/components/layout/Header';
|
||||||
|
import { ProjectNav } from '@/components/layout/ProjectNav';
|
||||||
|
import { NoteForm } from '@/components/notes/NoteForm';
|
||||||
|
import { ForbiddenError, NotFoundError } from '@/lib/errors';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default async function NotesPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ projectId: string }>;
|
||||||
|
searchParams: Promise<{ q?: string; page?: string }>;
|
||||||
|
}) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) redirect('/login');
|
||||||
|
const { projectId } = await params;
|
||||||
|
const { q, page } = await searchParams;
|
||||||
|
|
||||||
|
const projectService = createProjectService();
|
||||||
|
let project: Awaited<ReturnType<typeof projectService.getProject>>;
|
||||||
|
try {
|
||||||
|
project = projectService.getProject(user.id, Number(projectId));
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
|
||||||
|
redirect('/dashboard');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const noteService = createNoteService();
|
||||||
|
const { items } = noteService.listNotes(user.id, project.id, {
|
||||||
|
page: Number(page ?? '1') || 1,
|
||||||
|
search: q,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
|
<Header user={toPublicUser(user)} />
|
||||||
|
<ProjectNav projectId={project.id} active="notes" />
|
||||||
|
<main className="mx-auto max-w-3xl space-y-6 p-6">
|
||||||
|
<h1 className="text-2xl font-bold">Markdownメモ</h1>
|
||||||
|
<NoteForm projectId={project.id} />
|
||||||
|
<section className="space-y-3">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-400 dark:text-gray-500">
|
||||||
|
メモはありません。
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
items.map((note) => (
|
||||||
|
<a
|
||||||
|
key={note.id}
|
||||||
|
href={`/projects/${project.id}/notes/${note.id}`}
|
||||||
|
className="block rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm hover:shadow-md"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="font-semibold text-gray-800 dark:text-gray-100">
|
||||||
|
{note.isPinned === 1 && '📌 '}
|
||||||
|
{note.title}
|
||||||
|
</h3>
|
||||||
|
{note.tags && (
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{note.tags}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
{note.updatedAt}
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
app/projects/[projectId]/page.tsx
Normal file
148
app/projects/[projectId]/page.tsx
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createDashboardService } from '@/lib/api/services';
|
||||||
|
import { Header } from '@/components/layout/Header';
|
||||||
|
import { ProjectNav } from '@/components/layout/ProjectNav';
|
||||||
|
import { DashboardWidget } from '@/components/project/DashboardWidget';
|
||||||
|
import { ForbiddenError } from '@/lib/errors';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default async function ProjectOverviewPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ projectId: string }>;
|
||||||
|
}) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) redirect('/login');
|
||||||
|
const { projectId } = await params;
|
||||||
|
|
||||||
|
const service = createDashboardService();
|
||||||
|
let dashboard;
|
||||||
|
try {
|
||||||
|
dashboard = service.getProjectDashboard(user.id, Number(projectId));
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ForbiddenError) {
|
||||||
|
redirect('/dashboard');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { project } = dashboard;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
|
<Header user={toPublicUser(user)} />
|
||||||
|
<ProjectNav projectId={project.id} active="overview" />
|
||||||
|
<main className="mx-auto max-w-4xl space-y-6 p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">{project.name}</h1>
|
||||||
|
<span className="rounded bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs">
|
||||||
|
{project.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{project.description && (
|
||||||
|
<p className="text-gray-600 dark:text-gray-300">
|
||||||
|
{project.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<DashboardWidget title="進行中ToDo" empty="ありません">
|
||||||
|
{dashboard.inProgressTodos.map((t) => (
|
||||||
|
<p key={t.id} className="text-sm">
|
||||||
|
{t.title}
|
||||||
|
{t.dueDate ? `(期限: ${t.dueDate})` : ''}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</DashboardWidget>
|
||||||
|
|
||||||
|
<DashboardWidget title="期限が近いToDo (7日以内)" empty="ありません">
|
||||||
|
{dashboard.nearDueTodos.map((t) => (
|
||||||
|
<p key={t.id} className="text-sm">
|
||||||
|
{t.title}({t.dueDate})
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</DashboardWidget>
|
||||||
|
|
||||||
|
<DashboardWidget title="最新チャット (5件)" empty="ありません">
|
||||||
|
{dashboard.latestChat.map((m) => (
|
||||||
|
<p key={m.id} className="text-sm">
|
||||||
|
{m.body}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</DashboardWidget>
|
||||||
|
|
||||||
|
<DashboardWidget title="最新掲示板 (5件)" empty="ありません">
|
||||||
|
{dashboard.latestBoard.map((t) => (
|
||||||
|
<a
|
||||||
|
key={t.id}
|
||||||
|
href={`/projects/${project.id}/board/${t.id}`}
|
||||||
|
className="block text-sm text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
{t.isPinned === 1 ? '📌 ' : ''}
|
||||||
|
{t.title}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</DashboardWidget>
|
||||||
|
|
||||||
|
<DashboardWidget title="最新メモ (5件)" empty="ありません">
|
||||||
|
{dashboard.latestNotes.map((n) => (
|
||||||
|
<a
|
||||||
|
key={n.id}
|
||||||
|
href={`/projects/${project.id}/notes/${n.id}`}
|
||||||
|
className="block text-sm text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
{n.isPinned === 1 ? '📌 ' : ''}
|
||||||
|
{n.title}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</DashboardWidget>
|
||||||
|
|
||||||
|
<DashboardWidget title="最近のファイル (5件)" empty="ありません">
|
||||||
|
{dashboard.recentFiles.map((f) => (
|
||||||
|
<p key={f.id} className="text-sm">
|
||||||
|
{f.originalName}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</DashboardWidget>
|
||||||
|
|
||||||
|
<DashboardWidget title="次回ミーティング" empty="予定なし">
|
||||||
|
{dashboard.nextMeeting && (
|
||||||
|
<p className="text-sm">
|
||||||
|
{dashboard.nextMeeting.title}({dashboard.nextMeeting.startAt})
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</DashboardWidget>
|
||||||
|
|
||||||
|
<DashboardWidget title="マイルストーン進捗" empty="ありません">
|
||||||
|
{dashboard.milestones.map((m) => (
|
||||||
|
<div key={m.id}>
|
||||||
|
<p className="text-sm">
|
||||||
|
{m.title} - {m.progress}%
|
||||||
|
</p>
|
||||||
|
<div className="h-1.5 w-full rounded bg-gray-200 dark:bg-gray-700">
|
||||||
|
<div
|
||||||
|
className="h-1.5 rounded bg-blue-500"
|
||||||
|
style={{ width: `${m.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</DashboardWidget>
|
||||||
|
|
||||||
|
<DashboardWidget
|
||||||
|
title="最近のアクティビティ (10件)"
|
||||||
|
empty="ありません"
|
||||||
|
>
|
||||||
|
{dashboard.recentActivity.map((l) => (
|
||||||
|
<p key={l.id} className="text-sm">
|
||||||
|
{l.action}({l.targetType})
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</DashboardWidget>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
app/projects/[projectId]/search/page.tsx
Normal file
99
app/projects/[projectId]/search/page.tsx
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createSearchService, createProjectService } from '@/lib/api/services';
|
||||||
|
import { Header } from '@/components/layout/Header';
|
||||||
|
import { ProjectNav } from '@/components/layout/ProjectNav';
|
||||||
|
import { SearchForm } from '@/components/project/SearchForm';
|
||||||
|
import { ForbiddenError, NotFoundError } from '@/lib/errors';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
const TYPE_LINKS: Record<string, (id: number, projectId: number) => string> = {
|
||||||
|
thread: (id, p) => `/projects/${p}/board/${id}`,
|
||||||
|
note: (id, p) => `/projects/${p}/notes/${id}`,
|
||||||
|
todo: (_id, p) => `/projects/${p}/todos`,
|
||||||
|
file: (id) => `/api/files/${id}/download`,
|
||||||
|
event: (_id, p) => `/projects/${p}/calendar`,
|
||||||
|
meeting: (_id, p) => `/projects/${p}/meetings`,
|
||||||
|
milestone: (_id, p) => `/projects/${p}/milestones`,
|
||||||
|
chat: (_id, p) => `/projects/${p}/chat`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function SearchPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ projectId: string }>;
|
||||||
|
searchParams: Promise<{ q?: string; type?: string }>;
|
||||||
|
}) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) redirect('/login');
|
||||||
|
const { projectId } = await params;
|
||||||
|
const { q, type } = await searchParams;
|
||||||
|
|
||||||
|
const projectService = createProjectService();
|
||||||
|
let project: Awaited<ReturnType<typeof projectService.getProject>>;
|
||||||
|
try {
|
||||||
|
project = projectService.getProject(user.id, Number(projectId));
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
|
||||||
|
redirect('/dashboard');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchService = createSearchService();
|
||||||
|
const results = q
|
||||||
|
? searchService.search(user.id, project.id, {
|
||||||
|
q,
|
||||||
|
type: type as never,
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
|
<Header user={toPublicUser(user)} />
|
||||||
|
<ProjectNav projectId={project.id} active="search" />
|
||||||
|
<main className="mx-auto max-w-3xl space-y-6 p-6">
|
||||||
|
<h1 className="text-2xl font-bold">検索</h1>
|
||||||
|
<SearchForm
|
||||||
|
projectId={project.id}
|
||||||
|
initialQ={q ?? ''}
|
||||||
|
initialType={type ?? ''}
|
||||||
|
/>
|
||||||
|
<section className="space-y-2">
|
||||||
|
{q && results.length === 0 && (
|
||||||
|
<p className="text-sm text-gray-400 dark:text-gray-500">
|
||||||
|
該当する結果はありません。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{results.map((r) => {
|
||||||
|
const href =
|
||||||
|
TYPE_LINKS[r.type]?.(r.id, project.id) ??
|
||||||
|
`/projects/${project.id}`;
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={`${r.type}-${r.id}`}
|
||||||
|
href={href}
|
||||||
|
className="block rounded border bg-white dark:bg-gray-800 p-3 shadow-sm hover:shadow-md"
|
||||||
|
data-testid={`search-result-${r.type}-${r.id}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-medium text-gray-800 dark:text-gray-100">
|
||||||
|
{r.title}
|
||||||
|
</span>
|
||||||
|
<span className="rounded bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs">
|
||||||
|
{r.type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{r.snippet}
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
app/projects/[projectId]/settings/page.tsx
Normal file
57
app/projects/[projectId]/settings/page.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createProjectService } from '@/lib/api/services';
|
||||||
|
import { Header } from '@/components/layout/Header';
|
||||||
|
import { ProjectNav } from '@/components/layout/ProjectNav';
|
||||||
|
import { ProjectSettingsForm } from '@/components/project/ProjectSettingsForm';
|
||||||
|
import { DeleteProjectButton } from '@/components/project/DeleteProjectButton';
|
||||||
|
import { ForbiddenError, NotFoundError } from '@/lib/errors';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default async function ProjectSettingsPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ projectId: string }>;
|
||||||
|
}) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) {
|
||||||
|
redirect('/login');
|
||||||
|
}
|
||||||
|
const { projectId } = await params;
|
||||||
|
|
||||||
|
const service = createProjectService();
|
||||||
|
let project;
|
||||||
|
let canManage;
|
||||||
|
try {
|
||||||
|
project = service.getProject(user.id, Number(projectId));
|
||||||
|
canManage = service.getMemberRole(user.id, Number(projectId)) === 'admin';
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
|
||||||
|
redirect('/dashboard');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
|
<Header user={toPublicUser(user)} />
|
||||||
|
<ProjectNav projectId={project.id} active="settings" />
|
||||||
|
<main className="mx-auto max-w-2xl space-y-6 p-6">
|
||||||
|
<h1 className="text-2xl font-bold">プロジェクト設定</h1>
|
||||||
|
<div className="rounded-lg border bg-white dark:bg-gray-800 p-6 shadow-sm">
|
||||||
|
<ProjectSettingsForm project={project} canManage={canManage} />
|
||||||
|
</div>
|
||||||
|
{canManage && (
|
||||||
|
<div className="rounded-lg border border-red-200 bg-white dark:bg-gray-800 p-6 shadow-sm">
|
||||||
|
<h2 className="text-sm font-semibold text-red-700">危険操作</h2>
|
||||||
|
<p className="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
プロジェクトを削除すると、関連データも削除されます。
|
||||||
|
</p>
|
||||||
|
<DeleteProjectButton projectId={project.id} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
app/projects/[projectId]/todos/page.tsx
Normal file
51
app/projects/[projectId]/todos/page.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createTodoService, createProjectService } from '@/lib/api/services';
|
||||||
|
import { Header } from '@/components/layout/Header';
|
||||||
|
import { ProjectNav } from '@/components/layout/ProjectNav';
|
||||||
|
import { KanbanBoard } from '@/components/todo/KanbanBoard';
|
||||||
|
import { ForbiddenError, NotFoundError } from '@/lib/errors';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default async function TodosPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ projectId: string }>;
|
||||||
|
}) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) redirect('/login');
|
||||||
|
const { projectId } = await params;
|
||||||
|
|
||||||
|
const projectService = createProjectService();
|
||||||
|
let project: Awaited<ReturnType<typeof projectService.getProject>>;
|
||||||
|
try {
|
||||||
|
project = projectService.getProject(user.id, Number(projectId));
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
|
||||||
|
redirect('/dashboard');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const todoService = createTodoService();
|
||||||
|
const columns = todoService.getColumns(user.id, project.id);
|
||||||
|
const items = todoService.getItems(user.id, project.id);
|
||||||
|
const members = projectService.getMembers(user.id, project.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
|
<Header user={toPublicUser(user)} />
|
||||||
|
<ProjectNav projectId={project.id} active="todos" />
|
||||||
|
<main className="space-y-4 p-6">
|
||||||
|
<h1 className="text-2xl font-bold">ToDo / Kanban</h1>
|
||||||
|
<KanbanBoard
|
||||||
|
projectId={project.id}
|
||||||
|
columns={columns}
|
||||||
|
initialItems={items}
|
||||||
|
members={members}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
components/admin/BackupCreateButton.tsx
Normal file
47
components/admin/BackupCreateButton.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
export function BackupCreateButton() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function onCreate() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const res = await fetch('/api/admin/backups', { method: 'POST' });
|
||||||
|
setLoading(false);
|
||||||
|
if (res.ok) {
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
const b = (await res.json().catch(() => null)) as {
|
||||||
|
error?: { message?: string };
|
||||||
|
} | null;
|
||||||
|
setError(b?.error?.message ?? '作成に失敗しました');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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
|
||||||
|
type="button"
|
||||||
|
onClick={onCreate}
|
||||||
|
disabled={loading}
|
||||||
|
className="mt-2 rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
data-testid="create-backup"
|
||||||
|
>
|
||||||
|
{loading ? '作成中...' : 'バックアップ作成'}
|
||||||
|
</button>
|
||||||
|
{error && (
|
||||||
|
<p className="mt-2 text-sm text-red-600" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
components/board/CommentForm.tsx
Normal file
76
components/board/CommentForm.tsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
'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';
|
||||||
|
|
||||||
|
export function CommentForm({
|
||||||
|
projectId,
|
||||||
|
threadId,
|
||||||
|
}: {
|
||||||
|
projectId: number;
|
||||||
|
threadId: number;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const pickerRef = useRef<AttachmentPickerHandle>(null);
|
||||||
|
const [bodyMd, setBodyMd] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
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/${threadId}/comments`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ bodyMd, fileIds }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
setLoading(false);
|
||||||
|
if (res.ok) {
|
||||||
|
setBodyMd('');
|
||||||
|
pickerRef.current?.clear();
|
||||||
|
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-2">
|
||||||
|
<textarea
|
||||||
|
placeholder="コメント(Markdown)"
|
||||||
|
value={bodyMd}
|
||||||
|
onChange={(e) => setBodyMd(e.target.value)}
|
||||||
|
className="min-h-[80px] 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 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? '投稿中...' : 'コメント投稿'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
components/board/MarkdownBody.tsx
Normal file
20
components/board/MarkdownBody.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import rehypeSanitize from 'rehype-sanitize';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markdown本文を安全にレンダリングする。
|
||||||
|
* HTML直接入力は無効化し、rehype-sanitize でサニタイズする。
|
||||||
|
*/
|
||||||
|
export function MarkdownBody({ bodyMd }: { bodyMd: string }) {
|
||||||
|
return (
|
||||||
|
<div className="prose prose-sm max-w-none">
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
rehypePlugins={[rehypeSanitize]}
|
||||||
|
>
|
||||||
|
{bodyMd}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
components/board/ThreadForm.tsx
Normal file
111
components/board/ThreadForm.tsx
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
components/calendar/CalendarEventForm.tsx
Normal file
78
components/calendar/CalendarEventForm.tsx
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
export function CalendarEventForm({
|
||||||
|
projectId,
|
||||||
|
defaultDate,
|
||||||
|
}: {
|
||||||
|
projectId: number;
|
||||||
|
defaultDate: string;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [startAt, setStartAt] = useState(`${defaultDate}T10:00:00`);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const res = await fetch(`/api/projects/${projectId}/calendar/events`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ title, type: 'custom', startAt }),
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
if (res.ok) {
|
||||||
|
setTitle('');
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
const b = (await res.json().catch(() => null)) as {
|
||||||
|
error?: { message?: string };
|
||||||
|
} | null;
|
||||||
|
setError(b?.error?.message ?? '作成に失敗しました');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
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>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded border px-3 py-2"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium">開始日時</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={startAt}
|
||||||
|
onChange={(e) => setStartAt(e.target.value)}
|
||||||
|
className="mt-1 rounded border px-3 py-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? '作成中...' : 'イベント作成'}
|
||||||
|
</button>
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-600" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
185
components/calendar/CalendarView.tsx
Normal file
185
components/calendar/CalendarView.tsx
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
'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()]})`;
|
||||||
|
}
|
||||||
125
components/calendar/DayView.tsx
Normal file
125
components/calendar/DayView.tsx
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { CalendarEventView } from '@/services/ScheduleService';
|
||||||
|
import {
|
||||||
|
eventsOnDay,
|
||||||
|
eventHour,
|
||||||
|
formatTime,
|
||||||
|
getDayHours,
|
||||||
|
toISODate,
|
||||||
|
} from '@/lib/calendar/grid';
|
||||||
|
import {
|
||||||
|
SOURCE_COLORS,
|
||||||
|
SOURCE_CHIP_BORDER,
|
||||||
|
WEEKDAY_LABELS,
|
||||||
|
} from '@/components/calendar/sourceColors';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日表示(時間グリッド)。0:00〜23:00 を1時間刻みで表示し、
|
||||||
|
* 時刻付きイベントを該当時刻行に、終日(日付のみ)イベントを上部に配置する。
|
||||||
|
*/
|
||||||
|
export function DayView({
|
||||||
|
day,
|
||||||
|
events,
|
||||||
|
todayKey,
|
||||||
|
onSelectDate,
|
||||||
|
}: {
|
||||||
|
day: Date;
|
||||||
|
events: CalendarEventView[];
|
||||||
|
todayKey: string;
|
||||||
|
onSelectDate: (dateKey: string) => void;
|
||||||
|
}) {
|
||||||
|
const key = toISODate(day);
|
||||||
|
const dayEvents = eventsOnDay(events, key);
|
||||||
|
const allDay = dayEvents.filter((e) => eventHour(e.startAt) === null);
|
||||||
|
const timed = dayEvents.filter((e) => eventHour(e.startAt) !== null);
|
||||||
|
const hours = getDayHours();
|
||||||
|
const isToday = key === todayKey;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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 dark:text-gray-100">
|
||||||
|
{day.getMonth() + 1}月{day.getDate()}日(
|
||||||
|
{WEEKDAY_LABELS[day.getDay()]})
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
{isToday && (
|
||||||
|
<span className="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700">
|
||||||
|
今日
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{allDay.length > 0 && (
|
||||||
|
<div
|
||||||
|
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 dark:text-gray-400">
|
||||||
|
終日
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{allDay.map((e) => (
|
||||||
|
<button
|
||||||
|
key={e.key}
|
||||||
|
type="button"
|
||||||
|
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 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}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="max-h-[600px] overflow-y-auto">
|
||||||
|
{hours.map((h) => {
|
||||||
|
const hourEvents = timed.filter((e) => eventHour(e.startAt) === h);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={h}
|
||||||
|
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 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">
|
||||||
|
{hourEvents.map((e) => (
|
||||||
|
<button
|
||||||
|
key={e.key}
|
||||||
|
type="button"
|
||||||
|
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 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">
|
||||||
|
{formatTime(e.startAt)}
|
||||||
|
</span>{' '}
|
||||||
|
{e.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
components/calendar/EventDetailDialog.tsx
Normal file
116
components/calendar/EventDetailDialog.tsx
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
components/calendar/MilestoneForm.tsx
Normal file
73
components/calendar/MilestoneForm.tsx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
export function MilestoneForm({ projectId }: { projectId: number }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [dueDate, setDueDate] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const res = await fetch(`/api/projects/${projectId}/milestones`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ title, dueDate: dueDate || null }),
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
if (res.ok) {
|
||||||
|
setTitle('');
|
||||||
|
setDueDate('');
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
const b = (await res.json().catch(() => null)) as {
|
||||||
|
error?: { message?: string };
|
||||||
|
} | null;
|
||||||
|
setError(b?.error?.message ?? '作成に失敗しました');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
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>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded border px-3 py-2"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium">期限</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dueDate}
|
||||||
|
onChange={(e) => setDueDate(e.target.value)}
|
||||||
|
className="mt-1 rounded border px-3 py-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? '作成中...' : 'マイルストーン作成'}
|
||||||
|
</button>
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-600" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
components/calendar/MonthView.tsx
Normal file
108
components/calendar/MonthView.tsx
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { CalendarEventView } from '@/services/ScheduleService';
|
||||||
|
import { eventsOnDay, toISODate } from '@/lib/calendar/grid';
|
||||||
|
import {
|
||||||
|
SOURCE_COLORS,
|
||||||
|
SOURCE_CHIP_BORDER,
|
||||||
|
WEEKDAY_LABELS,
|
||||||
|
} from '@/components/calendar/sourceColors';
|
||||||
|
|
||||||
|
const MAX_VISIBLE_CHIPS = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 月表示グリッド。日曜始まりの7列グリッドで各日にイベントチップを表示する。
|
||||||
|
* 長いタイトルは truncate し、3件を超える分は「+N件」でまとめる。
|
||||||
|
*/
|
||||||
|
export function MonthView({
|
||||||
|
events,
|
||||||
|
weeks,
|
||||||
|
anchorMonth,
|
||||||
|
todayKey,
|
||||||
|
onSelectDate,
|
||||||
|
}: {
|
||||||
|
events: CalendarEventView[];
|
||||||
|
weeks: Date[][];
|
||||||
|
anchorMonth: number;
|
||||||
|
todayKey: string;
|
||||||
|
onSelectDate: (dateKey: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<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}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{weeks.map((week, wi) => (
|
||||||
|
<div key={wi} className="grid grid-cols-7 border-b last:border-b-0">
|
||||||
|
{week.map((day) => {
|
||||||
|
const key = toISODate(day);
|
||||||
|
const dayEvents = eventsOnDay(events, key);
|
||||||
|
const inMonth = day.getMonth() === anchorMonth;
|
||||||
|
const isToday = key === todayKey;
|
||||||
|
const visible = dayEvents.slice(0, MAX_VISIBLE_CHIPS);
|
||||||
|
const hidden = dayEvents.length - visible.length;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className={`min-h-[110px] border-r border-t p-1 last:border-r-0 ${
|
||||||
|
inMonth
|
||||||
|
? 'bg-white dark:bg-gray-800'
|
||||||
|
: 'bg-gray-50 dark:bg-gray-900'
|
||||||
|
}`}
|
||||||
|
data-testid={`calendar-day-${key}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectDate(key)}
|
||||||
|
className={`flex h-6 w-6 items-center justify-center rounded-full text-xs ${
|
||||||
|
isToday
|
||||||
|
? 'bg-blue-600 font-bold text-white'
|
||||||
|
: inMonth
|
||||||
|
? '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} の詳細を開く`}
|
||||||
|
>
|
||||||
|
{day.getDate()}
|
||||||
|
</button>
|
||||||
|
<div className="mt-1 space-y-0.5">
|
||||||
|
{visible.map((e) => (
|
||||||
|
<button
|
||||||
|
key={e.key}
|
||||||
|
type="button"
|
||||||
|
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 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}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{hidden > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectDate(key)}
|
||||||
|
aria-label={`${key}の残り${hidden}件を開く`}
|
||||||
|
className="block w-full truncate text-left text-xs text-gray-400 dark:text-gray-500 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
+{hidden}件
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
components/calendar/WeekView.tsx
Normal file
96
components/calendar/WeekView.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { CalendarEventView } from '@/services/ScheduleService';
|
||||||
|
import { eventsOnDay, formatTime, toISODate } from '@/lib/calendar/grid';
|
||||||
|
import {
|
||||||
|
SOURCE_COLORS,
|
||||||
|
SOURCE_CHIP_BORDER,
|
||||||
|
WEEKDAY_LABELS,
|
||||||
|
} from '@/components/calendar/sourceColors';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 週表示。日〜土の7日を並べ、各日のイベントを開始時刻付きで表示する。
|
||||||
|
*/
|
||||||
|
export function WeekView({
|
||||||
|
events,
|
||||||
|
days,
|
||||||
|
todayKey,
|
||||||
|
onSelectDate,
|
||||||
|
}: {
|
||||||
|
events: CalendarEventView[];
|
||||||
|
days: Date[];
|
||||||
|
todayKey: string;
|
||||||
|
onSelectDate: (dateKey: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<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 (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectDate(key)}
|
||||||
|
className="py-2 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
|
data-testid={`calendar-weekday-${key}`}
|
||||||
|
>
|
||||||
|
<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 dark:text-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{d.getDate()}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-7">
|
||||||
|
{days.map((d) => {
|
||||||
|
const key = toISODate(d);
|
||||||
|
const dayEvents = eventsOnDay(events, key);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="min-h-[400px] border-r p-1 last:border-r-0"
|
||||||
|
data-testid={`calendar-week-cell-${key}`}
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{dayEvents.length === 0 ? (
|
||||||
|
<p className="px-1 py-1 text-xs text-gray-300 dark:text-gray-600">
|
||||||
|
-
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
dayEvents.map((e) => (
|
||||||
|
<button
|
||||||
|
key={e.key}
|
||||||
|
type="button"
|
||||||
|
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 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">
|
||||||
|
{formatTime(e.startAt)}
|
||||||
|
</span>{' '}
|
||||||
|
{e.title}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
components/calendar/sourceColors.ts
Normal file
28
components/calendar/sourceColors.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
/** カレンダーイベントの来源ごとの色とラベル。 */
|
||||||
|
export const SOURCE_COLORS: Record<string, string> = {
|
||||||
|
event: 'bg-blue-100 text-blue-700',
|
||||||
|
milestone: 'bg-purple-100 text-purple-700',
|
||||||
|
todo: 'bg-yellow-100 text-yellow-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SOURCE_CHIP_BORDER: Record<string, string> = {
|
||||||
|
event: 'border-blue-200',
|
||||||
|
milestone: 'border-purple-200',
|
||||||
|
todo: 'border-yellow-200',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SOURCE_LABELS: Record<string, string> = {
|
||||||
|
event: 'イベント',
|
||||||
|
milestone: 'マイルストーン',
|
||||||
|
todo: 'ToDo',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WEEKDAY_LABELS = [
|
||||||
|
'日',
|
||||||
|
'月',
|
||||||
|
'火',
|
||||||
|
'水',
|
||||||
|
'木',
|
||||||
|
'金',
|
||||||
|
'土',
|
||||||
|
] as const;
|
||||||
175
components/chat/ChatWindow.tsx
Normal file
175
components/chat/ChatWindow.tsx
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState, type FormEvent } from 'react';
|
||||||
|
import type { ChatMessageWithAttachments } from '@/lib/types';
|
||||||
|
import { AttachmentPicker } from '@/components/files/AttachmentPicker';
|
||||||
|
import type { AttachmentPickerHandle } from '@/components/files/AttachmentPicker';
|
||||||
|
import { AttachmentList } from '@/components/files/AttachmentList';
|
||||||
|
|
||||||
|
interface ChatWindowProps {
|
||||||
|
projectId: number;
|
||||||
|
userName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatWindow({ projectId, userName }: ChatWindowProps) {
|
||||||
|
const [messages, setMessages] = useState<ChatMessageWithAttachments[]>([]);
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [pickerLoading, setPickerLoading] = useState(false);
|
||||||
|
const pickerRef = useRef<AttachmentPickerHandle>(null);
|
||||||
|
|
||||||
|
// 履歴取得
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`/api/projects/${projectId}/chat/messages`)
|
||||||
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
|
.then((data) => {
|
||||||
|
if (data?.items)
|
||||||
|
setMessages(data.items as ChatMessageWithAttachments[]);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
// SSE接続(自動再接続はEventSourceが行う)
|
||||||
|
useEffect(() => {
|
||||||
|
const es = new EventSource(`/api/projects/${projectId}/chat/stream`);
|
||||||
|
es.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(event.data) as {
|
||||||
|
type: string;
|
||||||
|
data: {
|
||||||
|
message?: ChatMessageWithAttachments;
|
||||||
|
id?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
if (parsed.type === 'chat.message.created' && parsed.data.message) {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.some((m) => m.id === parsed.data.message!.id)
|
||||||
|
? prev
|
||||||
|
: [parsed.data.message!, ...prev]
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
parsed.type === 'chat.message.updated' &&
|
||||||
|
parsed.data.message
|
||||||
|
) {
|
||||||
|
// 編集イベントは本文のみ更新し、添付は既存のものを保持する
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m) =>
|
||||||
|
m.id === parsed.data.message!.id
|
||||||
|
? { ...m, ...parsed.data.message!, attachments: m.attachments }
|
||||||
|
: m
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else if (parsed.type === 'chat.message.deleted' && parsed.data.id) {
|
||||||
|
setMessages((prev) => prev.filter((m) => m.id !== parsed.data.id));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 無効なペイロードは無視
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return () => es.close();
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
async function onSend(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!input.trim()) return;
|
||||||
|
setError(null);
|
||||||
|
setSending(true);
|
||||||
|
const fileIds = pickerRef.current?.getFileIds() ?? [];
|
||||||
|
const res = await fetch(`/api/projects/${projectId}/chat/messages`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ body: input, fileIds }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
// 送信したメッセージを即座に表示(SSE到着前でも見えるように楽観追加)。
|
||||||
|
// SSEの chat.message.created は id で重複排除する。
|
||||||
|
const data = (await res.json().catch(() => null)) as {
|
||||||
|
message?: ChatMessageWithAttachments;
|
||||||
|
} | null;
|
||||||
|
if (data?.message) {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.some((m) => m.id === data.message!.id)
|
||||||
|
? prev
|
||||||
|
: [data.message!, ...prev]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setInput('');
|
||||||
|
pickerRef.current?.clear();
|
||||||
|
} else {
|
||||||
|
const b = (await res.json().catch(() => null)) as {
|
||||||
|
error?: { message?: string };
|
||||||
|
} | null;
|
||||||
|
setError(b?.error?.message ?? '送信に失敗しました');
|
||||||
|
}
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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) => (
|
||||||
|
<li
|
||||||
|
key={m.id}
|
||||||
|
className={`flex flex-col ${m.authorId === 0 ? 'items-end' : 'items-start'}`}
|
||||||
|
data-message-id={m.id}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`max-w-[80%] rounded-lg px-3 py-2 ${
|
||||||
|
m.body.includes(`@${userName}`)
|
||||||
|
? '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}
|
||||||
|
</span>
|
||||||
|
{m.attachments && m.attachments.length > 0 && (
|
||||||
|
<div className="max-w-[80%]">
|
||||||
|
<AttachmentList attachments={m.attachments} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
{m.createdAt}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
onSubmit={onSend}
|
||||||
|
className="space-y-2 border-t p-3"
|
||||||
|
data-testid="chat-form"
|
||||||
|
>
|
||||||
|
<AttachmentPicker
|
||||||
|
ref={pickerRef}
|
||||||
|
projectId={projectId}
|
||||||
|
onLoadingChange={setPickerLoading}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
placeholder="メッセージを入力(@email でメンション)"
|
||||||
|
className="flex-1 rounded border px-3 py-2"
|
||||||
|
data-testid="chat-input"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={sending || pickerLoading}
|
||||||
|
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
data-testid="chat-send"
|
||||||
|
>
|
||||||
|
{sending ? '送信中...' : '送信'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{error && (
|
||||||
|
<p className="px-3 pb-2 text-sm text-red-600" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
components/files/AttachmentList.tsx
Normal file
93
components/files/AttachmentList.tsx
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { AttachmentView } from '@/lib/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添付ファイル一覧表示。画像はサムネイル(クリックでLightbox)、
|
||||||
|
* それ以外はダウンロードリンク。チャット/掲示板で共通利用。
|
||||||
|
*/
|
||||||
|
export function AttachmentList({
|
||||||
|
attachments,
|
||||||
|
}: {
|
||||||
|
attachments: AttachmentView[];
|
||||||
|
}) {
|
||||||
|
const [lightbox, setLightbox] = useState<AttachmentView | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lightbox) return;
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') setLightbox(null);
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [lightbox]);
|
||||||
|
|
||||||
|
if (attachments.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ul className="mt-1 flex flex-wrap gap-2" data-testid="attachment-list">
|
||||||
|
{attachments.map((a) => {
|
||||||
|
const url = `/api/files/${a.fileId}/download`;
|
||||||
|
const isImage = a.mimeType.startsWith('image/');
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={a.id}
|
||||||
|
className="overflow-hidden rounded border bg-gray-50 dark:bg-gray-900"
|
||||||
|
data-testid={`attachment-${a.id}`}
|
||||||
|
>
|
||||||
|
{isImage ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLightbox(a)}
|
||||||
|
className="block"
|
||||||
|
aria-label={`画像 ${a.originalName} を開く`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={a.originalName}
|
||||||
|
className="h-20 w-20 object-cover"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
className="flex h-20 w-28 flex-col items-center justify-center px-2 text-center text-xs text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
<span className="mb-1">📎</span>
|
||||||
|
<span className="w-full truncate" title={a.originalName}>
|
||||||
|
{a.originalName}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{lightbox && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
|
||||||
|
onClick={() => setLightbox(null)}
|
||||||
|
data-testid="attachment-lightbox"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLightbox(null)}
|
||||||
|
className="absolute right-4 top-4 rounded bg-black/50 px-2 py-0.5 text-2xl text-white hover:bg-black/70"
|
||||||
|
aria-label="閉じる"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
<img
|
||||||
|
src={`/api/files/${lightbox.fileId}/download`}
|
||||||
|
alt={lightbox.originalName}
|
||||||
|
className="max-h-full max-w-full rounded"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
components/files/AttachmentPicker.tsx
Normal file
166
components/files/AttachmentPicker.tsx
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ChangeEvent,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
export interface AttachmentPickerHandle {
|
||||||
|
getFileIds: () => number[];
|
||||||
|
clear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PickedFile {
|
||||||
|
fileId: number;
|
||||||
|
originalName: string;
|
||||||
|
mimeType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* チャット/掲示板用の添付ファイルピッカー。
|
||||||
|
* 選択したファイルを添付用エンドポイントへアップロードし、
|
||||||
|
* 親フォームは送信時に getFileIds() でファイルIDを取り出す。
|
||||||
|
* 送信完了後は clear() で状態をリセットする。
|
||||||
|
*/
|
||||||
|
export const AttachmentPicker = forwardRef<
|
||||||
|
AttachmentPickerHandle,
|
||||||
|
{ projectId: number; onLoadingChange?: (loading: boolean) => void }
|
||||||
|
>(function AttachmentPicker({ projectId, onLoadingChange }, ref) {
|
||||||
|
const [files, setFiles] = useState<PickedFile[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
function reportLoading(next: boolean) {
|
||||||
|
setLoading(next);
|
||||||
|
onLoadingChange?.(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => ({
|
||||||
|
getFileIds: () => files.map((f) => f.fileId),
|
||||||
|
clear: () => {
|
||||||
|
setFiles([]);
|
||||||
|
setError(null);
|
||||||
|
if (inputRef.current) inputRef.current.value = '';
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[files]
|
||||||
|
);
|
||||||
|
|
||||||
|
async function onFile(event: ChangeEvent<HTMLInputElement>) {
|
||||||
|
const selected = Array.from(event.target.files ?? []);
|
||||||
|
if (selected.length === 0) return;
|
||||||
|
reportLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const uploaded: PickedFile[] = [];
|
||||||
|
for (const f of selected) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', f);
|
||||||
|
const res = await fetch(`/api/projects/${projectId}/attachments`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const b = (await res.json().catch(() => null)) as {
|
||||||
|
error?: { message?: string };
|
||||||
|
} | null;
|
||||||
|
throw new Error(b?.error?.message ?? 'アップロードに失敗しました');
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
file: { id: number; originalName: string; mimeType: string };
|
||||||
|
};
|
||||||
|
uploaded.push({
|
||||||
|
fileId: data.file.id,
|
||||||
|
originalName: data.file.originalName,
|
||||||
|
mimeType: data.file.mimeType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setFiles((prev) => [...prev, ...uploaded]);
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof Error ? err.message : 'アップロードに失敗しました'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
reportLoading(false);
|
||||||
|
if (inputRef.current) inputRef.current.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFile(fileId: number) {
|
||||||
|
setFiles((prev) => prev.filter((f) => f.fileId !== fileId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2" data-testid="attachment-picker">
|
||||||
|
<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}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
onChange={onFile}
|
||||||
|
disabled={loading}
|
||||||
|
className="hidden"
|
||||||
|
data-testid="attachment-input"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{loading && (
|
||||||
|
<p
|
||||||
|
className="text-xs text-gray-500 dark:text-gray-400"
|
||||||
|
data-testid="attachment-loading"
|
||||||
|
>
|
||||||
|
アップロード中...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p className="text-xs text-red-600" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{files.length > 0 && (
|
||||||
|
<ul className="flex flex-wrap gap-2">
|
||||||
|
{files.map((f) => {
|
||||||
|
const isImage = f.mimeType.startsWith('image/');
|
||||||
|
const url = `/api/files/${f.fileId}/download`;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={f.fileId}
|
||||||
|
className="relative overflow-hidden rounded border bg-gray-50 dark:bg-gray-900"
|
||||||
|
data-testid={`attachment-picked-${f.fileId}`}
|
||||||
|
>
|
||||||
|
{isImage ? (
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={f.originalName}
|
||||||
|
className="h-16 w-16 object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="flex h-16 w-24 items-center justify-center px-1 text-center text-[10px] text-blue-600">
|
||||||
|
<span className="truncate" title={f.originalName}>
|
||||||
|
{f.originalName}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeFile(f.fileId)}
|
||||||
|
className="absolute right-0 top-0 rounded-bl bg-black/50 px-1 text-xs text-white hover:bg-black/70"
|
||||||
|
aria-label={`${f.originalName} を削除`}
|
||||||
|
data-testid={`attachment-remove-${f.fileId}`}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
113
components/files/FileList.tsx
Normal file
113
components/files/FileList.tsx
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import type { FileAsset } from '@/lib/types';
|
||||||
|
|
||||||
|
export function FileList({
|
||||||
|
files,
|
||||||
|
canDelete,
|
||||||
|
}: {
|
||||||
|
files: FileAsset[];
|
||||||
|
canDelete: boolean;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [lightbox, setLightbox] = useState<FileAsset | null>(null);
|
||||||
|
|
||||||
|
async function onDelete(fileId: number) {
|
||||||
|
const res = await fetch(`/api/files/${fileId}`, { method: 'DELETE' });
|
||||||
|
if (res.ok) router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-gray-400 dark:text-gray-500">
|
||||||
|
ファイルはありません。
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ul
|
||||||
|
className="grid grid-cols-2 gap-3 sm:grid-cols-3"
|
||||||
|
data-testid="file-list"
|
||||||
|
>
|
||||||
|
{files.map((file) => {
|
||||||
|
const isImage = file.mimeType.startsWith('image/');
|
||||||
|
const isPdf = file.mimeType === 'application/pdf';
|
||||||
|
const url = `/api/files/${file.id}/download`;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={file.id}
|
||||||
|
className="rounded-lg border bg-white dark:bg-gray-800 p-3 shadow-sm"
|
||||||
|
data-testid={`file-item-${file.id}`}
|
||||||
|
>
|
||||||
|
{isImage ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLightbox(file)}
|
||||||
|
className="block w-full"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={file.originalName}
|
||||||
|
className="h-24 w-full rounded object-cover"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
) : isPdf ? (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
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 dark:bg-gray-700 text-sm text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
ダウンロード
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<p
|
||||||
|
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 dark:text-gray-500">
|
||||||
|
{file.size} bytes
|
||||||
|
</p>
|
||||||
|
{canDelete && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onDelete(file.id)}
|
||||||
|
className="mt-1 text-xs text-red-600 hover:underline"
|
||||||
|
>
|
||||||
|
削除
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{lightbox && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
|
||||||
|
onClick={() => setLightbox(null)}
|
||||||
|
data-testid="lightbox"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={`/api/files/${lightbox.id}/download`}
|
||||||
|
alt={lightbox.originalName}
|
||||||
|
className="max-h-full max-w-full rounded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
components/files/Uploader.tsx
Normal file
60
components/files/Uploader.tsx
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
export function Uploader({ projectId }: { projectId: number }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function onFile(event: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
const res = await fetch(`/api/projects/${projectId}/files`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
if (res.ok) {
|
||||||
|
if (inputRef.current) inputRef.current.value = '';
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
const b = (await res.json().catch(() => null)) as {
|
||||||
|
error?: { message?: string };
|
||||||
|
} | null;
|
||||||
|
setError(b?.error?.message ?? 'アップロードに失敗しました');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm">
|
||||||
|
<label className="block text-sm font-medium">
|
||||||
|
ファイルをアップロード
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
onChange={onFile}
|
||||||
|
disabled={loading}
|
||||||
|
className="mt-2 block text-sm"
|
||||||
|
data-testid="file-input"
|
||||||
|
/>
|
||||||
|
{loading && (
|
||||||
|
<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">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
components/layout/Header.tsx
Normal file
52
components/layout/Header.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
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' });
|
||||||
|
router.push('/login');
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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-3 text-sm sm:gap-4">
|
||||||
|
<a
|
||||||
|
href="/dashboard"
|
||||||
|
className="hidden text-gray-600 hover:underline sm:inline dark:text-gray-300"
|
||||||
|
>
|
||||||
|
{t('nav.dashboard')}
|
||||||
|
</a>
|
||||||
|
<ThemeToggle />
|
||||||
|
<NotificationBadge />
|
||||||
|
<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 dark:text-blue-400"
|
||||||
|
>
|
||||||
|
{t('header.logout')}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
components/layout/ProjectNav.tsx
Normal file
71
components/layout/ProjectNav.tsx
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
'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' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* プロジェクト内サブナビゲーション。
|
||||||
|
* href は `/projects/{projectId}` を起点とする相対パス。
|
||||||
|
*/
|
||||||
|
export function ProjectNav({
|
||||||
|
projectId,
|
||||||
|
active,
|
||||||
|
}: {
|
||||||
|
projectId: number;
|
||||||
|
active:
|
||||||
|
| 'overview'
|
||||||
|
| 'board'
|
||||||
|
| 'notes'
|
||||||
|
| 'chat'
|
||||||
|
| 'todos'
|
||||||
|
| 'files'
|
||||||
|
| 'calendar'
|
||||||
|
| 'milestones'
|
||||||
|
| 'meetings'
|
||||||
|
| 'search'
|
||||||
|
| 'members'
|
||||||
|
| 'activity'
|
||||||
|
| 'settings';
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
data-testid="project-nav"
|
||||||
|
className="flex gap-4 overflow-x-auto whitespace-nowrap border-b bg-white px-6 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
|
||||||
|
>
|
||||||
|
{NAV_ITEMS.map((item) => {
|
||||||
|
const href = `/projects/${projectId}${item.href}`;
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={item.href}
|
||||||
|
href={href}
|
||||||
|
className={
|
||||||
|
active === item.activeKey
|
||||||
|
? 'font-medium text-blue-600 dark:text-blue-400'
|
||||||
|
: 'text-gray-600 hover:underline dark:text-gray-300'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t(item.key)}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
components/layout/Sidebar.tsx
Normal file
11
components/layout/Sidebar.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* プロジェクトページ共通のサイドバー。
|
||||||
|
* プロジェクト内ナビゲーションは ProjectNav に委譲する。
|
||||||
|
*/
|
||||||
|
export function Sidebar({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<aside className="w-48 shrink-0 border-r bg-gray-50 dark:bg-gray-900 p-4">
|
||||||
|
{children}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
components/layout/ThemeToggle.tsx
Normal file
24
components/layout/ThemeToggle.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
components/meetings/ConflictWarning.tsx
Normal file
46
components/meetings/ConflictWarning.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
export interface ScheduleConflict {
|
||||||
|
userId: number;
|
||||||
|
type: 'meeting' | 'calendar_event' | 'important_todo';
|
||||||
|
refId: number;
|
||||||
|
title: string;
|
||||||
|
startAt: string;
|
||||||
|
endAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_LABELS: Record<string, string> = {
|
||||||
|
meeting: 'ミーティング',
|
||||||
|
calendar_event: 'カレンダーイベント',
|
||||||
|
important_todo: '期限の近い重要タスク',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ConflictWarning({
|
||||||
|
conflicts,
|
||||||
|
}: {
|
||||||
|
conflicts: ScheduleConflict[];
|
||||||
|
}) {
|
||||||
|
if (conflicts.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-green-600" data-testid="no-conflicts">
|
||||||
|
スケジュールの重複はありません。
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded border border-yellow-300 bg-yellow-50 p-3"
|
||||||
|
data-testid="conflict-warning"
|
||||||
|
>
|
||||||
|
<p className="text-sm font-semibold text-yellow-800">
|
||||||
|
スケジュールの重複があります(作成は完了しています):
|
||||||
|
</p>
|
||||||
|
<ul className="mt-1 list-inside list-disc text-sm text-yellow-800">
|
||||||
|
{conflicts.map((c, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
[{TYPE_LABELS[c.type] ?? c.type}] {c.title}({c.startAt}
|
||||||
|
{c.endAt ? ` 〜 ${c.endAt}` : ''})
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
143
components/meetings/MeetingForm.tsx
Normal file
143
components/meetings/MeetingForm.tsx
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import {
|
||||||
|
ConflictWarning,
|
||||||
|
type ScheduleConflict,
|
||||||
|
} from '@/components/meetings/ConflictWarning';
|
||||||
|
|
||||||
|
interface Member {
|
||||||
|
userId: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MeetingForm({
|
||||||
|
projectId,
|
||||||
|
members,
|
||||||
|
}: {
|
||||||
|
projectId: number;
|
||||||
|
members: Member[];
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [startAt, setStartAt] = useState('');
|
||||||
|
const [endAt, setEndAt] = useState('');
|
||||||
|
const [selected, setSelected] = useState<number[]>([]);
|
||||||
|
const [conflicts, setConflicts] = useState<ScheduleConflict[] | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setConflicts(null);
|
||||||
|
const res = await fetch(`/api/projects/${projectId}/meetings`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ title, startAt, endAt, memberIds: selected }),
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
meeting: { id: number };
|
||||||
|
conflicts: ScheduleConflict[];
|
||||||
|
};
|
||||||
|
setConflicts(data.conflicts);
|
||||||
|
setTitle('');
|
||||||
|
setStartAt('');
|
||||||
|
setEndAt('');
|
||||||
|
setSelected([]);
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
const b = (await res.json().catch(() => null)) as {
|
||||||
|
error?: { message?: string };
|
||||||
|
} | null;
|
||||||
|
setError(b?.error?.message ?? '作成に失敗しました');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMember(userId: number) {
|
||||||
|
setSelected((prev) =>
|
||||||
|
prev.includes(userId)
|
||||||
|
? prev.filter((u) => u !== userId)
|
||||||
|
: [...prev, userId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
className="space-y-3 rounded-lg border bg-white dark:bg-gray-800 p-4 shadow-sm"
|
||||||
|
data-testid="meeting-form"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium">タイトル</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded border px-3 py-2"
|
||||||
|
required
|
||||||
|
data-testid="meeting-title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-sm font-medium">開始</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={startAt}
|
||||||
|
onChange={(e) => setStartAt(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded border px-3 py-2"
|
||||||
|
required
|
||||||
|
data-testid="meeting-start"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-sm font-medium">終了</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={endAt}
|
||||||
|
onChange={(e) => setEndAt(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded border px-3 py-2"
|
||||||
|
required
|
||||||
|
data-testid="meeting-end"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium">参加メンバー</label>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-2">
|
||||||
|
{members.map((m) => (
|
||||||
|
<label
|
||||||
|
key={m.userId}
|
||||||
|
className="flex items-center gap-1 rounded border px-2 py-1 text-sm"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.includes(m.userId)}
|
||||||
|
onChange={() => toggleMember(m.userId)}
|
||||||
|
/>
|
||||||
|
{m.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-600" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? '作成中...' : 'ミーティング作成'}
|
||||||
|
</button>
|
||||||
|
{conflicts && <ConflictWarning conflicts={conflicts} />}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
components/notes/NoteEditor.tsx
Normal file
104
components/notes/NoteEditor.tsx
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import type { ProjectNote } from '@/lib/types';
|
||||||
|
|
||||||
|
export function NoteEditor({
|
||||||
|
projectId,
|
||||||
|
note,
|
||||||
|
}: {
|
||||||
|
projectId: number;
|
||||||
|
note: ProjectNote;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [title, setTitle] = useState(note.title);
|
||||||
|
const [bodyMd, setBodyMd] = useState(note.bodyMd);
|
||||||
|
const [tags, setTags] = useState(note.tags ?? '');
|
||||||
|
const [isPinned, setIsPinned] = useState(note.isPinned === 1);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setSaved(false);
|
||||||
|
const res = await fetch(`/api/projects/${projectId}/notes/${note.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title,
|
||||||
|
bodyMd,
|
||||||
|
tags: tags || null,
|
||||||
|
isPinned: isPinned ? 1 : 0,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
if (res.ok) {
|
||||||
|
setSaved(true);
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<h2 className="text-sm font-semibold text-gray-700 dark:text-gray-200">
|
||||||
|
編集
|
||||||
|
</h2>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
className="w-full rounded border px-3 py-2"
|
||||||
|
required
|
||||||
|
maxLength={200}
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
value={bodyMd}
|
||||||
|
onChange={(e) => setBodyMd(e.target.value)}
|
||||||
|
className="min-h-[160px] w-full rounded border px-3 py-2"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="タグ(カンマ区切り)"
|
||||||
|
value={tags}
|
||||||
|
onChange={(e) => setTags(e.target.value)}
|
||||||
|
className="flex-1 rounded border px-3 py-2"
|
||||||
|
/>
|
||||||
|
<label className="flex items-center gap-1 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isPinned}
|
||||||
|
onChange={(e) => setIsPinned(e.target.checked)}
|
||||||
|
/>
|
||||||
|
ピン留め
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-600" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{saved && <p className="text-sm text-green-600">メモを更新しました</p>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? '保存中...' : '保存'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
components/notes/NoteForm.tsx
Normal file
78
components/notes/NoteForm.tsx
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
export function NoteForm({ projectId }: { projectId: number }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [bodyMd, setBodyMd] = useState('');
|
||||||
|
const [tags, setTags] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const res = await fetch(`/api/projects/${projectId}/notes`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ title, bodyMd, tags: tags || undefined }),
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = (await res.json()) as { note: { id: number } };
|
||||||
|
router.push(`/projects/${projectId}/notes/${data.note.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"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="タイトル"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
className="w-full rounded border px-3 py-2"
|
||||||
|
required
|
||||||
|
maxLength={200}
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
placeholder="本文(Markdown)"
|
||||||
|
value={bodyMd}
|
||||||
|
onChange={(e) => setBodyMd(e.target.value)}
|
||||||
|
className="min-h-[120px] w-full rounded border px-3 py-2"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="タグ(カンマ区切り)"
|
||||||
|
value={tags}
|
||||||
|
onChange={(e) => setTags(e.target.value)}
|
||||||
|
className="w-full rounded border px-3 py-2"
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-600" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? '作成中...' : 'メモ作成'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
components/notifications/MarkReadButton.tsx
Normal file
31
components/notifications/MarkReadButton.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
export function MarkReadButton({ notificationId }: { notificationId: number }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function onRead() {
|
||||||
|
setBusy(true);
|
||||||
|
const res = await fetch(`/api/notifications/${notificationId}/read`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
setBusy(false);
|
||||||
|
if (res.ok) {
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRead}
|
||||||
|
disabled={busy}
|
||||||
|
className="text-xs text-gray-500 dark:text-gray-400 hover:underline disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy ? '処理中...' : '既読にする'}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
components/notifications/NotificationBadge.tsx
Normal file
46
components/notifications/NotificationBadge.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export function NotificationBadge() {
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
fetch('/api/notifications?page=1')
|
||||||
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
|
.then((data) => {
|
||||||
|
if (active && data && typeof data.total === 'number') {
|
||||||
|
setCount(data.total);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (count === 0) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href="/notifications"
|
||||||
|
className="text-gray-600 dark:text-gray-300 hover:underline"
|
||||||
|
>
|
||||||
|
通知
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href="/notifications"
|
||||||
|
className="relative text-gray-600 dark:text-gray-300 hover:underline"
|
||||||
|
data-notification-count={count}
|
||||||
|
>
|
||||||
|
通知
|
||||||
|
<span className="ml-1 rounded-full bg-red-500 px-1.5 py-0.5 text-xs text-white">
|
||||||
|
{count > 99 ? '99+' : count}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
components/notifications/NotificationList.tsx
Normal file
58
components/notifications/NotificationList.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import type { Notification } from '@/lib/types';
|
||||||
|
import { MarkReadButton } from '@/components/notifications/MarkReadButton';
|
||||||
|
|
||||||
|
const TYPE_LABELS: Record<string, string> = {
|
||||||
|
mention: 'メンション',
|
||||||
|
todo_assigned: 'ToDo担当',
|
||||||
|
todo_due_soon: 'ToDo期限',
|
||||||
|
meeting_invited: 'ミーティング招待',
|
||||||
|
board_commented: '掲示板コメント',
|
||||||
|
project_added: 'プロジェクト追加',
|
||||||
|
file_shared: 'ファイル共有',
|
||||||
|
note_updated: 'メモ更新',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NotificationList({
|
||||||
|
notifications,
|
||||||
|
}: {
|
||||||
|
notifications: Notification[];
|
||||||
|
}) {
|
||||||
|
if (notifications.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-gray-400 dark:text-gray-500">
|
||||||
|
未読の通知はありません。
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
<span className="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700">
|
||||||
|
{TYPE_LABELS[notification.type] ?? notification.type}
|
||||||
|
</span>
|
||||||
|
<MarkReadButton notificationId={notification.id} />
|
||||||
|
</div>
|
||||||
|
<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 dark:text-gray-300">
|
||||||
|
{notification.body}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{notification.projectId !== null && (
|
||||||
|
<a
|
||||||
|
href={`/projects/${notification.projectId}`}
|
||||||
|
className="mt-1 inline-block text-xs text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
プロジェクトを開く
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
components/project/AddMemberForm.tsx
Normal file
84
components/project/AddMemberForm.tsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
export function AddMemberForm({ projectId }: { projectId: number }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [role, setRole] = useState('member');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const res = await fetch(`/api/projects/${projectId}/members`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, role }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setEmail('');
|
||||||
|
router.refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await res.json().catch(() => null)) as {
|
||||||
|
error?: { message?: string };
|
||||||
|
} | null;
|
||||||
|
setError(body?.error?.message ?? 'メンバー追加に失敗しました');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
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">
|
||||||
|
追加するユーザーのメールアドレス
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="member-email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded border px-3 py-2"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="member-role" className="block text-sm font-medium">
|
||||||
|
ロール
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="member-role"
|
||||||
|
value={role}
|
||||||
|
onChange={(e) => setRole(e.target.value)}
|
||||||
|
className="mt-1 rounded border px-3 py-2"
|
||||||
|
>
|
||||||
|
<option value="member">member</option>
|
||||||
|
<option value="admin">admin</option>
|
||||||
|
<option value="guest">guest</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? '追加中...' : 'メンバー追加'}
|
||||||
|
</button>
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-600" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
components/project/CreateProjectForm.tsx
Normal file
87
components/project/CreateProjectForm.tsx
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
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);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const res = await fetch('/api/projects', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, description: description || undefined }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = (await res.json()) as { project: { id: number } };
|
||||||
|
router.push(`/projects/${data.project.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await res.json().catch(() => null)) as {
|
||||||
|
error?: { message?: string };
|
||||||
|
} | null;
|
||||||
|
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 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 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
required
|
||||||
|
maxLength={200}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<label
|
||||||
|
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 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? t('project.creating') : t('auth.newProject')}
|
||||||
|
</button>
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-600 sm:full" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user