first commit

This commit is contained in:
2026-06-16 17:12:08 +09:00
commit e1bc5a1dfc
257 changed files with 49823 additions and 0 deletions
+191
View File
@@ -0,0 +1,191 @@
---
description: Flutter 모바일 앱 개발 규칙
---
> 이 파일은 `.claude/commands/flutter.md`에 위치한다.
> 사용법: `/flutter [요청 내용]`
---
## 언어 & 기본 원칙
- **Dart Sound Null Safety** 완벽 준수 (`?`, `!`, `late` 올바르게 사용)
- `dynamic` 타입 사용 **지양** → 명시적 타입 선언
- 모든 주석은 **한국어**로 작성
---
## 프로젝트 구조
```
lib/
├── main.dart
├── app.dart ← MaterialApp / 라우터 설정
├── core/
│ ├── constants/ ← 상수 (색상, 크기, 텍스트 등)
│ ├── theme/ ← 앱 테마 정의
│ ├── api/ ← Dio 인스턴스(dio_client), 에러 사전(error_lexicon), 인터셉터
│ ├── storage/ ← flutter_secure_storage 래퍼 (선택)
│ └── utils/ ← 순수 유틸 함수
├── features/
│ └── user/
│ ├── data/
│ │ ├── models/ ← JSON 직렬화 모델
│ │ └── repositories/ ← API 호출 구현체
│ ├── domain/
│ │ └── entities/ ← 순수 비즈니스 엔티티
│ └── presentation/
│ ├── pages/ ← 화면 단위 위젯
│ ├── widgets/ ← 재사용 위젯
│ └── providers/ ← 상태 관리
└── shared/
└── widgets/ ← 앱 공통 위젯
```
---
## 위젯 작성 원칙
```dart
// ✅ 좋은 예 — 작은 위젯으로 분리
class UserProfilePage extends StatelessWidget {
const UserProfilePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: const _AppBar(),
body: const _ProfileBody(),
);
}
}
class _ProfileBody extends StatelessWidget {
const _ProfileBody();
@override
Widget build(BuildContext context) {
return Column(
children: [
const _AvatarSection(),
const _InfoSection(),
],
);
}
}
```
- 위젯 트리 depth가 깊어지면 **커스텀 위젯으로 분리**
- `build()` 메서드 내 비즈니스 로직 작성 **금지**
- `const` 생성자 적극 활용 (불필요한 리빌드 방지)
---
## 반응형 UI
```dart
// ❌ 하드코딩 금지
Container(width: 375, height: 200)
// ✅ 반응형 처리
Container(
width: MediaQuery.of(context).size.width * 0.9,
child: ...,
)
// ✅ 유연한 레이아웃
Expanded(flex: 2, child: LeftPanel()),
Expanded(flex: 1, child: RightPanel()),
```
- `Expanded`, `Flexible`, `MediaQuery`, `LayoutBuilder` 적극 활용
- 하드코딩 픽셀값 사용 **금지**
- 폰트 크기는 `Theme.of(context).textTheme` 활용
---
## 상태 관리
```dart
// Riverpod 예시 (권장)
@riverpod
Future<User> fetchUser(FetchUserRef ref, int id) async {
final repo = ref.watch(userRepositoryProvider);
return repo.getUser(id);
}
// 위젯에서 사용
class UserWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final userAsync = ref.watch(fetchUserProvider(userId));
return userAsync.when(
data: (user) => Text(user.name),
loading: () => const CircularProgressIndicator(),
error: (e, _) => const ErrorWidget(),
);
}
}
```
- **Riverpod** 권장 (Provider는 레거시로 간주)
- `StatefulWidget`은 애니메이션, 폼 등 로컬 UI 상태에만 제한적 사용
---
## API 통신 (Dio + 인터셉터)
```dart
// core/api/dio_client.dart
class DioClient {
late final Dio _dio;
DioClient() {
// flutter_dotenv 로 .env 로드, 미설정 시 로컬 기본값
final baseUrl = dotenv.maybeGet('API_URL') ?? 'http://localhost:3000/api';
_dio = Dio(BaseOptions(baseUrl: baseUrl));
_dio.interceptors.add(AuthInterceptor()); // 토큰 자동 첨부
_dio.interceptors.add(ErrorInterceptor()); // 에러 코드 처리 (error_lexicon 참조)
}
}
// 에러 인터셉터 — CLAUDE.md 에러 맵 기준 한국어 메시지 처리
class ErrorInterceptor extends Interceptor {
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
// 에러 코드 파싱 후 SnackBar/Dialog 표시
}
}
```
---
## 보안 (토큰 저장)
```dart
// flutter_secure_storage 사용
const storage = FlutterSecureStorage();
// 저장 (키 이름은 dio_client 인터셉터와 통일: jwt_token)
await storage.write(key: 'jwt_token', value: token);
// 읽기
final token = await storage.read(key: 'jwt_token');
// 삭제 (로그아웃)
await storage.deleteAll();
```
- JWT, 민감 정보는 반드시 `flutter_secure_storage` 사용
- `SharedPreferences`에 토큰 저장 **금지**
---
## 성능 & 최신 트렌드 추가 규칙
- **모델 직렬화**: `json_serializable` + `freezed` 사용 권장 (불변 모델)
- **이미지**: `cached_network_image`로 네트워크 이미지 캐싱
- **목록**: 긴 목록은 `ListView.builder` 사용 (전체 렌더링 금지)
- **에러 핸들링**: `Either<Failure, Success>` 패턴 고려 (`fpdart` 패키지)
- **라우팅**: `go_router` 사용 권장 (딥링크, 중첩 라우팅 지원)
- **환경 분리**: `--dart-define` 또는 `flutter_dotenv`로 dev/prod 분리
+151
View File
@@ -0,0 +1,151 @@
---
description: NestJS 백엔드 개발 규칙
---
> 이 파일은 `.claude/commands/nestjs.md`에 위치한다.
> 사용법: `/nestjs [요청 내용]`
---
## 아키텍처 원칙
### 관심사 분리 (Separation of Concerns)
```
Controller → HTTP 라우팅, 요청/응답 파싱만 담당
Service → 모든 비즈니스 로직 처리
Repository → 데이터 접근 계층 (TypeORM)
DTO → 요청/응답 데이터 형식 정의 및 검증
```
- `Controller`에 비즈니스 로직 작성 **금지**
- `new` 키워드로 수동 객체 생성 **지양** → 의존성 주입(DI) 활용
### 모듈 구조
```
src/
├── modules/
│ └── user/
│ ├── user.module.ts
│ ├── user.controller.ts
│ ├── user.service.ts
│ ├── user.repository.ts ← 커스텀 Repository (선택)
│ └── dto/
│ ├── create-user.dto.ts
│ └── update-user.dto.ts
├── common/
│ ├── interceptors/ ← 응답 래핑, 에러 처리
│ ├── filters/ ← 전역 Exception Filter
│ ├── guards/ ← 인증/권한 Guard
│ ├── decorators/ ← 커스텀 데코레이터
│ └── pipes/ ← 전역 Validation Pipe
└── shared/
└── utils/ ← 순수 유틸 함수
```
---
## DTO 작성 규칙
```typescript
import { IsString, IsEmail, IsNotEmpty, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateUserDto {
@ApiProperty({ description: '사용자 이메일', example: 'user@example.com' })
@IsEmail({}, { message: '올바른 이메일 형식을 입력해 주세요.' })
@IsNotEmpty()
email: string;
@ApiProperty({ description: '비밀번호 (최소 8자)', example: 'password123' })
@IsString()
@MinLength(8, { message: '비밀번호는 최소 8자 이상이어야 합니다.' })
password: string;
}
```
- 모든 DTO는 `class-validator` + `class-transformer` 사용
- 에러 메시지는 **한국어**로 작성
- `@ApiProperty()` 반드시 포함 (Swagger 명세용)
---
## 표준 응답 인터셉터
```typescript
// common/interceptors/transform.interceptor.ts
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
map((data) => ({
success: true,
data: data ?? null,
})),
);
}
}
```
- `main.ts`에서 전역 등록: `app.useGlobalInterceptors(new TransformInterceptor())`
- 실패 응답(`success: false`)은 아래 Exception Filter가 생성한다.
---
## 전역 Exception Filter
```typescript
// common/filters/http-exception.filter.ts
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
// HTTP 상태 → 에러 코드 매핑 후 { success: false, error: { code, message } } 반환
// SYS_001 / AUTH_001 / VAL_001 등 CLAUDE.md 에러 맵 참조
}
}
```
---
## Swagger 필수 적용
```typescript
// main.ts
const config = new DocumentBuilder()
.setTitle('API 문서')
.setDescription('프로젝트 API 명세서')
.setVersion('1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api-docs', app, document);
```
- 모든 Controller에 `@ApiTags()` 적용
- 모든 엔드포인트에 `@ApiOperation()`, `@ApiResponse()` 작성
- 모든 DTO에 `@ApiProperty()` 작성
---
## 인증 (JWT)
- Access Token: 만료 시간 짧게 (15분 ~ 1시간)
- Refresh Token: HttpOnly Secure 쿠키로 전달
- `@nestjs/jwt` + `@nestjs/passport` 사용
- Guard를 통한 라우트 보호
---
## 성능 & 최신 트렌드 추가 규칙
- **캐싱**: 자주 조회되는 데이터는 `@nestjs/cache-manager` (Redis) 활용 고려
- **Rate Limiting**: `@nestjs/throttler`로 API 남용 방지
- **Pagination**: 목록 API는 반드시 커서 기반 또는 오프셋 페이지네이션 적용
- **Logging**: `winston` 또는 `@nestjs/common Logger` 사용, `console.log` 지양
- **Health Check**: `@nestjs/terminus``/health` 엔드포인트 구성 권장
- **Versioning**: API 버전 관리 (`/v1/`, `/v2/`) 적용 고려
+161
View File
@@ -0,0 +1,161 @@
---
description: Vue 3 프론트엔드 개발 규칙
---
> 이 파일은 `.claude/commands/vue3.md`에 위치한다.
> 사용법: `/vue3 [요청 내용]`
---
## 컴포넌트 작성 원칙
### 필수 구조
```vue
<script setup lang="ts">
// 1. import
// 2. Props / Emits 정의
// 3. Store / Composable 호출
// 4. 반응형 상태 (ref, reactive, computed)
// 5. 함수 정의
// 6. Lifecycle Hooks
// 7. watch
</script>
<template>
<!-- 시맨틱 HTML 태그 사용 -->
</template>
<style scoped>
/* rem 단위 사용, 인라인 스타일 금지 */
</style>
```
- `Options API` 사용 **금지**`Composition API` + `<script setup>` 필수
- 인라인 스타일(`style=""`) 사용 **금지**`<style scoped>` 사용
- 모든 px 값은 **rem으로 변환** (기준: 16px = 1rem)
---
## Props / Emits 타입 정의
```typescript
// Props
interface Props {
title: string
count?: number
items: string[]
}
const props = withDefaults(defineProps<Props>(), {
count: 0,
})
// Emits
interface Emits {
(e: 'update', value: string): void
(e: 'close'): void
}
const emit = defineEmits<Emits>()
```
---
## 상태 관리 (Pinia)
```typescript
// stores/user.store.ts
export const useUserStore = defineStore('user', () => {
// 상태
const user = ref<User | null>(null)
const isLoggedIn = computed(() => !!user.value)
// 액션
async function fetchUser(id: number) {
// API 호출
}
return { user, isLoggedIn, fetchUser }
})
```
- 도메인별 Store 파일 분리 (`user.store.ts`, `auth.store.ts` 등)
- Store 내 API 호출은 허용, 단 에러 처리는 인터셉터에 위임
---
## API 호출 (Composables + Axios)
```typescript
// composables/useApi.ts — 중앙 Axios 인스턴스
const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api', // env.d.ts 에 타입 선언
withCredentials: true, // HttpOnly 쿠키 전송
})
// 응답 인터셉터 — 표준 응답 언래핑 + 에러 코드 전역 처리
api.interceptors.response.use(
(response) => response.data.data, // { success, data } → data 언래핑
(error) => {
const code = error.response?.data?.error?.code
// 에러 코드별 Toast 알림 처리 (CLAUDE.md 에러 맵 참조)
return Promise.reject(error)
},
)
```
```typescript
// composables/useUser.ts — 도메인별 API Composable
export function useUser() {
const loading = ref(false)
const error = ref<string | null>(null)
async function getUser(id: number) {
loading.value = true
try {
return await api.get(`/users/${id}`)
} finally {
loading.value = false
}
}
return { loading, error, getUser }
}
```
- API 호출 로직은 반드시 `composables/` 폴더에 분리
- 컴포넌트 내 `axios.get()` 직접 호출 **금지**
---
## 라우터 (Vue Router)
```typescript
// 코드 스플리팅 — 모든 페이지 컴포넌트는 lazy import
const routes = [
{
path: '/dashboard',
component: () => import('@/pages/DashboardPage.vue'),
meta: { requiresAuth: true },
},
]
// 네비게이션 가드 — 인증 처리
router.beforeEach((to) => {
const authStore = useAuthStore()
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
return '/login'
}
})
```
---
## 성능 & 최신 트렌드 추가 규칙
- **컴포넌트 분리**: 200줄 이상 컴포넌트는 분리 고려
- **`defineAsyncComponent`**: 무거운 컴포넌트는 비동기 로딩 적용
- **`v-memo`**: 반복 렌더링 최적화 필요 시 활용
- **`<Suspense>`**: 비동기 컴포넌트 로딩 상태 처리에 활용
- **환경변수**: 모든 설정값은 `import.meta.env.VITE_*` 형태로 관리
- **절대경로**: `@/` 경로 alias 사용 (상대경로 `../../` 지양)
- **접근성(a11y)**: `aria-label`, `role`, `tabindex` 등 기본 접근성 속성 포함
+52
View File
@@ -0,0 +1,52 @@
# ==========================================
# 환경 변수 (민감정보) — 절대 커밋 금지
# ==========================================
.env
.env.*
!.env.example
!.env.*.example
# ==========================================
# 도커 영속 데이터
# ==========================================
data/
!data/.gitkeep
db-data/
redis-data/
# ==========================================
# 의존성/빌드 산출물
# ==========================================
node_modules/
dist/
build/
coverage/
.tsbuildinfo
*.tsbuildinfo
# ==========================================
# 로그
# ==========================================
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# ==========================================
# IDE / OS
# ==========================================
.idea/
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
.DS_Store
Thumbs.db
# ==========================================
# 캐시
# ==========================================
.cache/
.eslintcache
.npm/
+115
View File
@@ -0,0 +1,115 @@
# 프로젝트 개요
이 저장소는 **풀스택 모노레포 기본 템플릿**이다. 누구나 클론하여 바로 개발을 시작할 수 있도록 공통 규칙과 언어별 규칙을 정의한다.
| 디렉터리 | 스택 | 역할 | 개발 규칙 (슬래시 명령) |
| ----------- | -------------- | ------------------------ | ---------------------------------------- |
| `backend/` | NestJS (TS) | REST API 서버 | `/nestjs` → @.claude/commands/nestjs.md |
| `frontend/` | Vue 3 (TS) | 웹 프론트엔드 | `/vue3` → @.claude/commands/vue3.md |
| `flutter/` | Flutter (Dart) | 모바일 앱 | `/flutter` → @.claude/commands/flutter.md |
| 루트 | Docker Compose | 로컬/운영 오케스트레이션 | @command.md |
세 클라이언트는 동일한 **표준 응답 포맷**과 **에러 코드 맵**을 공유한다 (아래 참조).
---
## 코딩 규칙
### 공통
- 모든 코드 주석은 **한국어**로 작성
- 웹(`frontend`)·서버(`backend`)는 100% TypeScript, 모바일(`flutter`)은 Dart Sound Null Safety 준수
- 컴파일러/린터 **Warning 방치 금지** — 커밋 전 각 프로젝트 `lint` / `analyze` 통과
- 유틸 함수는 순수 함수로 분리 — TS는 `shared/utils/`, Dart는 `core/utils/`
- 매직 넘버·하드코딩 문자열 지양 → 상수 또는 환경변수로 분리
- 절대경로 alias 사용 (TS `@/`, Dart `package:`), 깊은 상대경로(`../../`) 지양
### 표준 응답 포맷 (Global Response Wrapper)
모든 API 응답은 아래 구조로 통일한다.
(SSOT: backend `TransformInterceptor` + `HttpExceptionFilter`)
```jsonc
// 성공
{ "success": true, "data": { /* ... */ } }
// 실패
{ "success": false, "error": { "code": "VAL_001", "message": "..." } }
```
- 프론트/플러터 인터셉터는 `success: true``data` 만 언래핑하여 반환한다.
- `success: false` 이거나 HTTP 4xx/5xx 면 `error.code` 로 메시지를 매핑한다.
### Git 커밋 컨벤션
`<type>: <제목>` 형식 — type: **feat / fix / refactor / docs / chore / perf / test / style**
- 예: `feat: 사용자 목록 페이지네이션 추가`
- 제목은 한국어, 명령형 현재 시제로 작성
- 한 커밋은 하나의 논리적 변경만 포함
### 에러 코드 맵핑
세 플랫폼이 공유하는 **단일 진실 공급원(SSOT)**. 메시지를 수정할 때는 아래 세 파일을 함께 갱신한다.
- backend: `src/common/filters/http-exception.filter.ts`
- frontend: `src/composables/useApi.ts` (`ErrorCodeLexicon`)
- flutter: `lib/core/api/error_lexicon.dart` (`ErrorLexicon`)
| 코드 | HTTP / 상황 | 메시지 |
| -------- | -------------------- | ------------------------------------------------------- |
| SYS_001 | 500 / 미정의 예외 | "일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요." |
| AUTH_001 | 401 | "로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요." |
| AUTH_002 | 세션 만료 (클라이언트) | "안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요." |
| AUTH_003 | 403 | "해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다." |
| VAL_001 | 400 / 422 (Validation) | "입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)" |
| RES_001 | 404 | "요청하신 정보나 페이지를 찾을 수 없습니다." |
| BIZ_001 | 200(비즈니스 실패) / 기타 | "요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요." |
> 비즈니스 예외는 `HttpException` 응답의 `message` 로 사용자 노출 문구를 재정의할 수 있다.
### 보안
- `.env*` 등 민감정보 Git 커밋 금지 (`.gitignore` 확인) — 예시는 `*.example`(예: `.env.development.example`, `.env.example`) 로만 공유
- JWT는 HttpOnly·Secure 쿠키 사용 (웹 `localStorage` 금지) / 모바일은 `flutter_secure_storage` 사용
- TypeORM ORM 쿼리 사용으로 SQL Injection 차단 (Raw 쿼리 시 파라미터 바인딩 필수)
- 입력값은 DTO(`class-validator`) 검증을 거친 뒤 서비스 계층에 전달
- 보안 헤더(`helmet`)·CORS·Rate Limiting(`@nestjs/throttler`) 적용
- Docker 컨테이너 내부 root 실행 금지 (비-root 사용자로 구동)
---
## 환경변수 구조 (단일 진실 공급원: 루트 `.env`)
이 템플릿은 **루트 `.env.{APP_ENV}` 가 모든 설정의 단일 진실 공급원(SSOT)** 이다.
값을 한 곳에서만 관리하기 위해, 서비스(backend/frontend)는 자체 env에 값을 중복 작성하지 않고 **루트 → docker-compose 가 주입한 값을 그대로 사용**한다.
```
루트 .env.{APP_ENV}
│ (docker compose --env-file 로 로드)
├─▶ backend : env_file + environment 로 DB/Redis/CORS/포트 주입
│ (backend/.env.{APP_ENV} 는 "존재"만 하면 됨, 내용 최소)
├─▶ frontend : BACKEND_API_URL → build arg(VITE_API_BASE_URL) 로 주입
├─▶ db : DB_USER/DB_PASSWORD/DB_NAME, DB_DATA_DIR 볼륨
└─▶ redis : REDIS_PASSWORD, REDIS_DATA_DIR 볼륨
```
규칙:
- **새 설정값은 루트 `.env.{APP_ENV}` 에 먼저 추가**하고, 필요한 서비스에 `docker-compose.yml``environment` / `args` 로 전달한다.
- 서비스 자체 env 파일에는 **루트에서 내려주지 않는 고유 값만** 작성한다. (예: 백엔드 `JWT_SECRET`)
- `docker-compose.yml``env_file` 지시자 때문에 `backend/.env.{APP_ENV}` 파일은 비어 있어도 **존재해야 한다.**
- **단독 실행(non-Docker)** 시에는 주입이 없으므로, 각 서비스 `.env.{APP_ENV}` 의 주석 처리된 예시 값을 활성화해 사용한다.
- **Flutter 는 예외** — docker-compose 대상이 아니므로 `flutter/.env` 에서 `API_URL` 을 독립적으로 관리한다.
- 모든 env 파일은 커밋 금지, `*.example` 만 커밋한다. (`.gitignore``!*.example` 재포함 규칙)
> 루트 env의 대표 키: `PROJECT_NAME`, `APP_ENV`, `BUILD_MODE`, 각종 `*_PORT`, `BACKEND_API_URL`, `CORS_ORIGIN`, `DB_*`/`DB_DATA_DIR`, `REDIS_*`/`REDIS_DATA_DIR`. 실제 목록은 `.env.development.example` 참조.
---
## 개발 규칙 참조
- 백엔드(NestJS): @.claude/commands/nestjs.md
- 프론트엔드(Vue 3): @.claude/commands/vue3.md
- 모바일(Flutter): @.claude/commands/flutter.md
- 실행/배포 명령어: @command.md
+109
View File
@@ -0,0 +1,109 @@
# 풀스택 모노레포 기본 템플릿
NestJS(백엔드) · Vue 3(웹) · Flutter(모바일)를 하나의 저장소에서 운영하는 풀스택 템플릿입니다.
세 클라이언트는 **동일한 표준 응답 포맷**과 **공통 에러 코드 맵**을 공유하며, Docker Compose로 한 번에 구동됩니다.
## 구성
| 디렉터리 | 스택 | 역할 | 포트(기본) |
| ----------- | -------------- | ------------------------ | ---------- |
| `backend/` | NestJS + TypeORM | REST API 서버 | 3000 |
| `frontend/` | Vue 3 + Vite | 웹 프론트엔드 | 5173 |
| `flutter/` | Flutter + Riverpod | 모바일 앱 | - |
| `db` | PostgreSQL | 데이터베이스 | 5432 |
| `redis` | Redis | 캐시 / 세션 | 6379 |
## 사전 요구사항
- Docker / Docker Compose (권장 구동 방식)
- 단독 실행 시: Node.js `^20.19 || >=22.12`, Flutter SDK `^3.11`
## 빠른 시작 (Docker)
```bash
# 1) 환경변수 파일 생성 (예제 복사)
# 실제 설정값은 루트 .env.development 한 곳에서 관리한다 (SSOT).
# backend/frontend 는 docker-compose 가 값을 주입하므로 예제만 복사하면 된다.
cp .env.development.example .env.development # ← 여기에만 값 입력
cp backend/.env.development.example backend/.env.development # (존재만 필요, 내용 최소)
cp frontend/.env.development.example frontend/.env.development # (단독 실행 시에만 사용)
# 2) 개발 환경 기동 (코드만 수정 시)
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d
# 3) 의존성 변경 시에만 --build
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d --build
```
기동 후:
- 웹: <http://localhost:5173>
- API: <http://localhost:3000/api>
- Swagger 문서: <http://localhost:3000/api-docs>
전체 실행/배포 명령어는 [`command.md`](./command.md) 참조.
## 단독 실행 (Docker 미사용)
```bash
# 백엔드
cd backend && npm install && npm run start:dev
# 프론트엔드
cd frontend && npm install && npm run dev
# 모바일
cd flutter && cp .env.example .env && flutter pub get && flutter run
```
> 단독 실행 시 백엔드 `.env.development` 의 `DB_HOST`/`REDIS_HOST` 를 `localhost` 로 두고,
> PostgreSQL·Redis 를 별도로 띄워야 합니다.
## 환경변수
`.env*` 는 커밋하지 않습니다. 각 디렉터리의 `*.example` 파일을 복사해 사용하세요.
**루트 `.env.{env}` 가 단일 진실 공급원(SSOT)** 입니다. backend·frontend 는 docker-compose 가
루트 값을 주입하므로 자체 env 에 값을 중복 작성하지 않습니다. (자세한 위임 구조는 [`CLAUDE.md`](./CLAUDE.md) 의 "환경변수 구조" 참조)
| 파일 | 용도 | 값 작성 위치 |
| ----------------------------- | ---------------------------------------- | --------------------- |
| `.env.{env}.example` | 루트 — 모든 설정의 SSOT (compose 주입) | ✅ 여기에 작성 |
| `backend/.env.{env}.example` | 백엔드 — 고유 시크릿(JWT 등)만 | 최소 (존재 필수) |
| `frontend/.env.{env}.example` | 프론트 — 단독 실행용 `VITE_API_BASE_URL` | 단독 실행 시에만 |
| `flutter/.env.example` | 모바일 — `API_URL` (compose 비대상) | ✅ 독립 관리 |
`{env}` = `development` | `production`
## 표준 응답 포맷
```jsonc
// 성공
{ "success": true, "data": { /* ... */ } }
// 실패
{ "success": false, "error": { "code": "VAL_001", "message": "..." } }
```
에러 코드 맵과 코딩 규칙 전문은 [`CLAUDE.md`](./CLAUDE.md) 및 `.claude/commands/` 의 언어별 규칙 문서를 참고하세요.
## 개발 규칙
| 영역 | 문서 |
| --------- | ------------------------------------------------- |
| 공통/보안 | [`CLAUDE.md`](./CLAUDE.md) |
| NestJS | [`.claude/commands/nestjs.md`](./.claude/commands/nestjs.md) |
| Vue 3 | [`.claude/commands/vue3.md`](./.claude/commands/vue3.md) |
| Flutter | [`.claude/commands/flutter.md`](./.claude/commands/flutter.md) |
## 디렉터리 구조
```
.
├── backend/ # NestJS API (Controller / Service / DTO / 공통 필터·인터셉터)
├── frontend/ # Vue 3 (pages / composables / stores / router)
├── flutter/ # Flutter (core / features / widgets)
├── docker-compose.yml # 베이스 정의
├── docker-compose.dev.yml # 개발 오버라이드 (핫리로드, 포트 노출)
├── CLAUDE.md # 공통 코딩 규칙 + 에러 코드 맵 (SSOT)
└── command.md # 실행/배포 명령어
```
@@ -0,0 +1,329 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>내 업무 · 지시한 업무 — Relay</title>
<link rel="preconnect" href="https://cdn.jsdelivr.net">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f4f5f7; --panel: #ffffff; --border: #e6e8ec; --border-strong: #d7dae0;
--text: #16181d; --text-2: #5b626e; --text-3: #9298a3;
--accent: #4f46e5; --accent-hover: #4338ca; --accent-weak: #eef0fe; --accent-border: #c7ccfb;
--green: #1f9d57; --green-weak: #e8f6ee;
--blue: #1d4ed8; --blue-weak: #e8efff; --blue-border: #c9dbff;
--red: #d6455d; --red-weak: #fdeef0; --red-border: #f1c0c8;
--amber: #b7791f; --amber-weak: #fdf4e3; --amber-border: #f3e2bd;
--radius: 6px; --radius-sm: 4px; --shadow-sm: 0 1px 2px rgba(20,24,33,.05);
}
html, body { background: var(--bg); color: var(--text); font-family: "Pretendard", -apple-system, system-ui, sans-serif; font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
body { min-height: 100vh; }
/* topbar */
.topbar { height: 52px; background: var(--panel); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 28px; padding: 0 20px; position: sticky; top: 0; z-index: 50; }
.brand { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 15px; letter-spacing: -.2px; }
.brand .logo { width: 26px; height: 26px; border-radius: 7px; background: linear-gradient(135deg, #5b54f0, #4338ca); display: grid; place-items: center; color: #fff; font-size: 15px; font-weight: 800; box-shadow: 0 2px 6px rgba(79,70,229,.35); }
.topnav { display: flex; align-items: center; gap: 2px; }
.topnav a { color: var(--text-2); text-decoration: none; font-size: 13.5px; font-weight: 500; padding: 7px 12px; border-radius: var(--radius-sm); line-height: 1; white-space: nowrap; }
.topnav a:hover { background: #f1f2f4; color: var(--text); }
.topnav a.active { color: var(--accent); background: var(--accent-weak); font-weight: 600; }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.icon-btn { width: 32px; height: 32px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-2); display: grid; place-items: center; cursor: pointer; }
.icon-btn:hover { background: #f1f2f4; color: var(--text); }
.me { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-size: 11.5px; font-weight: 700; color: #fff; background: #0ea5a3; margin-left: 4px; }
/* page */
.page { max-width: 1040px; margin: 0 auto; padding: 0 24px 70px; }
.pagehead { display: flex; align-items: flex-end; gap: 12px; padding: 24px 0 16px; }
.pagehead h1 { font-size: 22px; font-weight: 700; letter-spacing: -.4px; white-space: nowrap; }
.pagehead .sub { font-size: 13px; color: var(--text-2); padding-bottom: 2px; white-space: nowrap; }
.pagehead .new-btn { margin-left: auto; height: 36px; padding: 0 15px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; border: 1px solid var(--accent); background: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); cursor: pointer; display: inline-flex; align-items: center; gap: 6px; text-decoration: none; white-space: nowrap; }
.pagehead .new-btn:hover { background: var(--accent-hover); }
.pagehead .new-btn svg { width: 15px; height: 15px; }
/* view toggle (담당 / 지시) */
.viewtabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 18px; }
.viewtab { display: flex; align-items: center; gap: 8px; padding: 10px 4px; margin-right: 18px; font-size: 14px; font-weight: 600; color: var(--text-2); background: none; border: none; border-bottom: 2px solid transparent; margin-bottom: -1px; cursor: pointer; white-space: nowrap; text-decoration: none; }
.viewtab:hover { color: var(--text); }
.viewtab.active { color: var(--accent); border-bottom-color: var(--accent); }
.viewtab .vt-count { font-size: 11px; font-weight: 600; color: var(--text-2); background: #f1f2f4; border-radius: 20px; height: 17px; min-width: 17px; padding: 0 6px; display: inline-flex; align-items: center; justify-content: center; line-height: 1; }
.viewtab.active .vt-count { background: var(--accent-weak); color: var(--accent); }
.viewtab .vt-flag { font-size: 11px; font-weight: 600; color: var(--amber); background: var(--amber-weak); border: 1px solid var(--amber-border); border-radius: 20px; padding: 1px 7px; line-height: 1.4; white-space: nowrap; }
/* toolbar */
.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; }
.search { display: flex; align-items: center; gap: 8px; height: 36px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 11px; background: #fff; width: 260px; }
.search:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
.search svg { width: 16px; height: 16px; color: var(--text-3); }
.search input { border: none; outline: none; flex: 1; min-width: 0; font-family: inherit; font-size: 13.5px; background: transparent; }
.search input::placeholder { color: var(--text-3); }
.repofilter { display: flex; align-items: center; gap: 7px; height: 36px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 11px; background: #fff; font-size: 13px; font-weight: 500; color: var(--text-2); cursor: pointer; white-space: nowrap; }
.repofilter svg { width: 15px; height: 15px; color: var(--text-3); }
.sort { margin-left: auto; display: flex; align-items: center; gap: 6px; height: 36px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 11px; background: #fff; font-size: 13px; font-weight: 500; color: var(--text-2); cursor: pointer; white-space: nowrap; }
.sort svg { width: 15px; height: 15px; color: var(--text-3); }
/* highlight banner for pending approvals */
.pending-note { display: flex; align-items: center; gap: 10px; padding: 11px 15px; background: var(--amber-weak); border: 1px solid var(--amber-border); border-radius: 9px; margin-bottom: 22px; font-size: 13px; color: #8a5a12; }
.pending-note svg { width: 17px; height: 17px; color: var(--amber); flex-shrink: 0; }
.pending-note span { white-space: nowrap; }
.pending-note b { font-weight: 700; }
/* status group */
.group { margin-bottom: 22px; }
.group-head { display: flex; align-items: center; gap: 9px; margin-bottom: 9px; padding-left: 2px; }
.gh-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
.gh-dot.changes { background: var(--red); } .gh-dot.prog { background: var(--blue); }
.gh-dot.todo { background: #c4c8cf; } .gh-dot.review { background: var(--amber); } .gh-dot.done { background: var(--green); }
.gh-label { font-size: 13px; font-weight: 700; color: var(--text); white-space: nowrap; }
.gh-count { font-size: 11.5px; font-weight: 600; color: var(--text-2); background: #eceef1; border-radius: 20px; height: 18px; min-width: 18px; padding: 0 6px; display: inline-flex; align-items: center; justify-content: center; line-height: 1; }
.gh-action { margin-left: 4px; font-size: 11.5px; font-weight: 600; color: var(--amber); white-space: nowrap; }
/* list + rows */
.list { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow-sm); overflow: hidden; }
.list.attn { border-color: var(--amber-border); box-shadow: 0 0 0 3px rgba(183,121,31,.08), var(--shadow-sm); }
.task-row { display: flex; align-items: center; gap: 14px; padding: 13px 18px; border-top: 1px solid var(--border); cursor: pointer; text-decoration: none; color: inherit; }
.task-row:first-child { border-top: none; }
.task-row:hover { background: #fafbfc; }
.st-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
.st-dot.done { background: var(--green); } .st-dot.prog { background: var(--blue); } .st-dot.review { background: var(--amber); } .st-dot.todo { background: #c4c8cf; } .st-dot.changes { background: var(--red); }
.task-main { flex: 1; min-width: 0; }
.task-title { font-size: 14px; font-weight: 600; color: var(--text); display: flex; align-items: center; gap: 8px; }
.task-title .ttext { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.task-title .tid { font-size: 12px; font-weight: 600; color: var(--text-3); flex-shrink: 0; }
.task-row:hover .task-title .ttext { color: var(--accent); }
.task-row.is-done .task-title { color: var(--text-2); }
.task-meta { display: flex; align-items: center; gap: 12px; margin-top: 4px; font-size: 12px; color: var(--text-3); }
.repo-chip { display: inline-flex; align-items: center; gap: 5px; font-weight: 600; color: var(--text-2); white-space: nowrap; }
.repo-chip svg { width: 13px; height: 13px; color: var(--accent); flex-shrink: 0; }
.mi { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
.mi svg { width: 13px; height: 13px; }
.mi.attn { color: var(--amber); font-weight: 600; }
.meta-sep { width: 3px; height: 3px; border-radius: 50%; background: #d7dae0; flex-shrink: 0; }
/* assignee avatars */
.mini-stack { display: inline-flex; align-items: center; }
.mini-av { width: 18px; height: 18px; border-radius: 50%; display: grid; place-items: center; font-size: 9px; font-weight: 700; color: #fff; box-shadow: 0 0 0 1.5px #fff; flex-shrink: 0; }
.mini-stack .mini-av + .mini-av { margin-left: -5px; }
.av-a { background: #e0518d; } .av-b { background: #3d8bf2; } .av-c { background: #16a37b; }
.av-d { background: #b3631f; } .av-e { background: #7c5cf0; } .av-f { background: #d0982a; }
.task-right { display: flex; align-items: center; gap: 14px; flex-shrink: 0; }
.due { display: flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--text-2); font-weight: 500; white-space: nowrap; }
.due svg { width: 14px; height: 14px; color: var(--text-3); }
.due.overdue { color: var(--red); font-weight: 600; }
.due.overdue svg { color: var(--red); }
.dday { font-size: 11px; font-weight: 700; padding: 1px 7px; border-radius: 20px; white-space: nowrap; line-height: 1.5; }
.dday.urgent { color: var(--red); background: var(--red-weak); border: 1px solid var(--red-border); }
.dday.soon { color: var(--amber); background: var(--amber-weak); border: 1px solid var(--amber-border); }
.dday.calm { color: var(--text-2); background: #f1f2f4; border: 1px solid var(--border); }
.st-badge { font-size: 11.5px; font-weight: 600; padding: 3px 10px; border-radius: 20px; white-space: nowrap; min-width: 64px; text-align: center; }
.st-badge.prog { color: var(--blue); background: var(--blue-weak); }
.st-badge.review { color: var(--amber); background: var(--amber-weak); }
.st-badge.changes { color: var(--red); background: var(--red-weak); }
.st-badge.done { color: var(--green); background: var(--green-weak); }
.st-badge.todo { color: var(--text-2); background: #f1f2f4; }
/* action button on pending-approval rows */
.review-btn { font-size: 12px; font-weight: 600; color: #fff; background: var(--amber); border: 1px solid var(--amber); border-radius: var(--radius); padding: 5px 11px; white-space: nowrap; }
</style>
</head>
<body>
<header class="topbar">
<div class="brand"><div class="logo">R</div>Relay</div>
<nav class="topnav">
<a href="내 업무.html" class="active">내 업무</a>
<a href="저장소 목록.html">저장소</a>
</nav>
<div class="topbar-right">
<button class="icon-btn" title="검색"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg></button>
<button class="icon-btn" title="알림"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg></button>
<div class="me"></div>
</div>
</header>
<div class="page" data-screen-label="내 업무 · 지시한 업무">
<div class="pagehead">
<h1>내 업무</h1>
<span class="sub">나에게 배정되었거나 내가 지시한 업무를 한곳에서 관리하세요.</span>
<a class="new-btn" href="업무 작성.html"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5v14"/></svg>새 업무 지시</a>
</div>
<!-- 담당 / 지시 토글 -->
<div class="viewtabs">
<a class="viewtab" href="내 업무.html"><span class="tb-label">담당 업무</span> <span class="vt-count">6</span></a>
<a class="viewtab active" href="내 업무 (지시한 업무).html"><span class="tb-label">지시한 업무</span> <span class="vt-count">5</span> <span class="vt-flag">승인 대기 1</span></a>
</div>
<!-- 툴바 -->
<div class="toolbar">
<div class="search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>
<input type="text" placeholder="업무 검색…">
</div>
<div class="repofilter">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
모든 저장소
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
</div>
<div class="sort">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5h10M11 9h7M11 13h4M3 17l3 3 3-3M6 18V4"/></svg>
마감 임박순
</div>
</div>
<!-- 승인 대기 강조 배너 -->
<div class="pending-note">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8v4l3 2"/><circle cx="12" cy="12" r="9"/></svg>
<span><b>1건</b>의 업무가 내 승인을 기다리고 있어요. 검토 후 승인하거나 수정을 요청하세요.</span>
</div>
<!-- 승인 대기 (내가 결정) -->
<div class="group">
<div class="group-head"><span class="gh-dot review"></span><span class="gh-label">승인 대기</span><span class="gh-count">1</span><span class="gh-action">· 내 승인 필요</span></div>
<div class="list attn">
<a class="task-row" href="업무 상세.html">
<span class="st-dot review"></span>
<span class="task-main">
<span class="task-title"><span class="ttext">메인 배너 이미지 2종 (가로/세로) 디자인</span> <span class="tid">#9</span></span>
<span class="task-meta">
<span class="repo-chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>3분기 신제품 런칭 캠페인</span>
<span class="meta-sep"></span>
<span class="mi"><span class="mini-stack"><span class="mini-av av-b"></span><span class="mini-av av-d"></span></span></span>
<span class="meta-sep"></span>
<span class="mi attn">박지훈님이 2시간 전 승인 요청</span>
</span>
</span>
<span class="task-right">
<span class="due"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>6월 18일 <span class="dday soon">D-3</span></span>
<span class="review-btn">검토하기</span>
</span>
</a>
</div>
</div>
<!-- 진행 중 -->
<div class="group">
<div class="group-head"><span class="gh-dot prog"></span><span class="gh-label">진행 중</span><span class="gh-count">2</span></div>
<div class="list">
<a class="task-row" href="업무 상세.html">
<span class="st-dot prog"></span>
<span class="task-main">
<span class="task-title"><span class="ttext">법무 검토 요청 및 회신 반영</span> <span class="tid">#11</span></span>
<span class="task-meta">
<span class="repo-chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>3분기 신제품 런칭 캠페인</span>
<span class="meta-sep"></span>
<span class="mi"><span class="mini-stack"><span class="mini-av av-c"></span></span> 이도윤</span>
</span>
</span>
<span class="task-right">
<span class="due overdue"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>6월 12일 <span class="dday urgent">3일 지남</span></span>
<span class="st-badge prog">진행 중</span>
</span>
</a>
<a class="task-row" href="업무 상세.html">
<span class="st-dot prog"></span>
<span class="task-main">
<span class="task-title"><span class="ttext">SNS 콘텐츠 2차 시안 제작</span> <span class="tid">#16</span></span>
<span class="task-meta">
<span class="repo-chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>3분기 신제품 런칭 캠페인</span>
<span class="meta-sep"></span>
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>3/6</span>
<span class="meta-sep"></span>
<span class="mi"><span class="mini-stack"><span class="mini-av av-e"></span></span> 정민호</span>
</span>
</span>
<span class="task-right">
<span class="due"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>6월 19일 <span class="dday soon">D-4</span></span>
<span class="st-badge prog">진행 중</span>
</span>
</a>
</div>
</div>
<!-- 수정 요청 -->
<div class="group">
<div class="group-head"><span class="gh-dot changes"></span><span class="gh-label">수정 요청함</span><span class="gh-count">1</span></div>
<div class="list">
<a class="task-row" href="업무 상세 (수정 요청).html">
<span class="st-dot changes"></span>
<span class="task-main">
<span class="task-title"><span class="ttext">상세페이지 카피라이팅</span> <span class="tid">#8</span></span>
<span class="task-meta">
<span class="repo-chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>3분기 신제품 런칭 캠페인</span>
<span class="meta-sep"></span>
<span class="mi"><span class="mini-stack"><span class="mini-av av-c"></span></span> 이도윤에게 수정 요청 · 1일 전</span>
</span>
</span>
<span class="task-right">
<span class="due"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>6월 20일 <span class="dday soon">D-5</span></span>
<span class="st-badge changes">수정 요청</span>
</span>
</a>
</div>
</div>
<!-- 할 일 -->
<div class="group">
<div class="group-head"><span class="gh-dot todo"></span><span class="gh-label">할 일</span><span class="gh-count">1</span></div>
<div class="list">
<a class="task-row" href="업무 상세.html">
<span class="st-dot todo"></span>
<span class="task-main">
<span class="task-title"><span class="ttext">SNS 콘텐츠 1차 시안</span> <span class="tid">#7</span></span>
<span class="task-meta">
<span class="repo-chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>3분기 신제품 런칭 캠페인</span>
<span class="meta-sep"></span>
<span class="mi"><span class="mini-stack"><span class="mini-av av-e"></span></span> 정민호</span>
</span>
</span>
<span class="task-right">
<span class="due"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>6월 23일 <span class="dday calm">D-8</span></span>
<span class="st-badge todo">할 일</span>
</span>
</a>
</div>
</div>
<!-- 완료 -->
<div class="group">
<div class="group-head"><span class="gh-dot done"></span><span class="gh-label">완료</span><span class="gh-count">2</span></div>
<div class="list">
<a class="task-row is-done" href="업무 상세 (승인 완료).html">
<span class="st-dot done"></span>
<span class="task-main">
<span class="task-title"><span class="ttext">캠페인 핵심 메시지 시안 작성</span> <span class="tid">#6</span></span>
<span class="task-meta">
<span class="repo-chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>3분기 신제품 런칭 캠페인</span>
<span class="meta-sep"></span>
<span class="mi"><span class="mini-stack"><span class="mini-av av-a"></span></span> 김서연</span>
</span>
</span>
<span class="task-right">
<span class="due">6월 9일 승인됨</span>
<span class="st-badge done">완료</span>
</span>
</a>
<a class="task-row is-done" href="업무 상세 (승인 완료).html">
<span class="st-dot done"></span>
<span class="task-main">
<span class="task-title"><span class="ttext">퍼포먼스 광고 소재 세팅</span> <span class="tid">#4</span></span>
<span class="task-meta">
<span class="repo-chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>3분기 신제품 런칭 캠페인</span>
<span class="meta-sep"></span>
<span class="mi"><span class="mini-stack"><span class="mini-av av-b"></span></span> 박지훈</span>
</span>
</span>
<span class="task-right">
<span class="due">6월 5일 승인됨</span>
<span class="st-badge done">완료</span>
</span>
</a>
</div>
</div>
</div>
</body>
</html>
+314
View File
@@ -0,0 +1,314 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>내 업무 — Relay</title>
<link rel="preconnect" href="https://cdn.jsdelivr.net">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f4f5f7; --panel: #ffffff; --border: #e6e8ec; --border-strong: #d7dae0;
--text: #16181d; --text-2: #5b626e; --text-3: #9298a3;
--accent: #4f46e5; --accent-hover: #4338ca; --accent-weak: #eef0fe; --accent-border: #c7ccfb;
--green: #1f9d57; --green-weak: #e8f6ee;
--blue: #1d4ed8; --blue-weak: #e8efff; --blue-border: #c9dbff;
--red: #d6455d; --red-weak: #fdeef0; --red-border: #f1c0c8;
--amber: #b7791f; --amber-weak: #fdf4e3; --amber-border: #f3e2bd;
--radius: 6px; --radius-sm: 4px; --shadow-sm: 0 1px 2px rgba(20,24,33,.05);
}
html, body { background: var(--bg); color: var(--text); font-family: "Pretendard", -apple-system, system-ui, sans-serif; font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
body { min-height: 100vh; }
/* topbar */
.topbar { height: 52px; background: var(--panel); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 28px; padding: 0 20px; position: sticky; top: 0; z-index: 50; }
.brand { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 15px; letter-spacing: -.2px; }
.brand .logo { width: 26px; height: 26px; border-radius: 7px; background: linear-gradient(135deg, #5b54f0, #4338ca); display: grid; place-items: center; color: #fff; font-size: 15px; font-weight: 800; box-shadow: 0 2px 6px rgba(79,70,229,.35); }
.topnav { display: flex; align-items: center; gap: 2px; }
.topnav a { color: var(--text-2); text-decoration: none; font-size: 13.5px; font-weight: 500; padding: 7px 12px; border-radius: var(--radius-sm); line-height: 1; white-space: nowrap; }
.topnav a:hover { background: #f1f2f4; color: var(--text); }
.topnav a.active { color: var(--accent); background: var(--accent-weak); font-weight: 600; }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.icon-btn { width: 32px; height: 32px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-2); display: grid; place-items: center; cursor: pointer; }
.icon-btn:hover { background: #f1f2f4; color: var(--text); }
.me { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-size: 11.5px; font-weight: 700; color: #fff; background: #0ea5a3; margin-left: 4px; }
/* page */
.page { max-width: 1040px; margin: 0 auto; padding: 0 24px 70px; }
.pagehead { display: flex; align-items: flex-end; gap: 12px; padding: 24px 0 16px; }
.pagehead h1 { font-size: 22px; font-weight: 700; letter-spacing: -.4px; white-space: nowrap; }
.pagehead .sub { font-size: 13px; color: var(--text-2); padding-bottom: 2px; white-space: nowrap; }
/* view toggle (담당 / 지시) */
.viewtabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 18px; }
.viewtab { display: flex; align-items: center; gap: 8px; padding: 10px 4px; margin-right: 18px; font-size: 14px; font-weight: 600; color: var(--text-2); background: none; border: none; border-bottom: 2px solid transparent; margin-bottom: -1px; cursor: pointer; white-space: nowrap; text-decoration: none; }
.viewtab:hover { color: var(--text); }
.viewtab.active { color: var(--accent); border-bottom-color: var(--accent); }
.viewtab .vt-count { font-size: 11px; font-weight: 600; color: var(--text-2); background: #f1f2f4; border-radius: 20px; height: 17px; min-width: 17px; padding: 0 6px; display: inline-flex; align-items: center; justify-content: center; line-height: 1; }
.viewtab.active .vt-count { background: var(--accent-weak); color: var(--accent); }
.viewtab .vt-flag { font-size: 11px; font-weight: 600; color: var(--amber); background: var(--amber-weak); border: 1px solid var(--amber-border); border-radius: 20px; padding: 1px 7px; line-height: 1.4; white-space: nowrap; }
/* toolbar */
.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; }
.search { display: flex; align-items: center; gap: 8px; height: 36px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 11px; background: #fff; width: 260px; }
.search:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
.search svg { width: 16px; height: 16px; color: var(--text-3); }
.search input { border: none; outline: none; flex: 1; min-width: 0; font-family: inherit; font-size: 13.5px; background: transparent; }
.search input::placeholder { color: var(--text-3); }
.repofilter { display: flex; align-items: center; gap: 7px; height: 36px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 11px; background: #fff; font-size: 13px; font-weight: 500; color: var(--text-2); cursor: pointer; white-space: nowrap; }
.repofilter svg { width: 15px; height: 15px; color: var(--text-3); }
.sort { margin-left: auto; display: flex; align-items: center; gap: 6px; height: 36px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 11px; background: #fff; font-size: 13px; font-weight: 500; color: var(--text-2); cursor: pointer; white-space: nowrap; }
.sort svg { width: 15px; height: 15px; color: var(--text-3); }
/* status group */
.group { margin-bottom: 22px; }
.group-head { display: flex; align-items: center; gap: 9px; margin-bottom: 9px; padding-left: 2px; }
.gh-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
.gh-dot.changes { background: var(--red); } .gh-dot.prog { background: var(--blue); }
.gh-dot.todo { background: #c4c8cf; } .gh-dot.review { background: var(--amber); } .gh-dot.done { background: var(--green); }
.gh-label { font-size: 13px; font-weight: 700; color: var(--text); white-space: nowrap; }
.gh-count { font-size: 11.5px; font-weight: 600; color: var(--text-2); background: #eceef1; border-radius: 20px; height: 18px; min-width: 18px; padding: 0 6px; display: inline-flex; align-items: center; justify-content: center; line-height: 1; }
/* list + rows */
.list { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow-sm); overflow: hidden; }
.task-row { display: flex; align-items: center; gap: 14px; padding: 13px 18px; border-top: 1px solid var(--border); cursor: pointer; text-decoration: none; color: inherit; }
.task-row:first-child { border-top: none; }
.task-row:hover { background: #fafbfc; }
.st-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
.st-dot.done { background: var(--green); } .st-dot.prog { background: var(--blue); } .st-dot.review { background: var(--amber); } .st-dot.todo { background: #c4c8cf; } .st-dot.changes { background: var(--red); }
.task-main { flex: 1; min-width: 0; }
.task-title { font-size: 14px; font-weight: 600; color: var(--text); display: flex; align-items: center; gap: 8px; }
.task-title .ttext { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.task-title .tid { font-size: 12px; font-weight: 600; color: var(--text-3); flex-shrink: 0; }
.task-row:hover .task-title .ttext { color: var(--accent); }
.task-row.is-done .task-title { color: var(--text-2); }
.task-meta { display: flex; align-items: center; gap: 12px; margin-top: 4px; font-size: 12px; color: var(--text-3); }
.repo-chip { display: inline-flex; align-items: center; gap: 5px; font-weight: 600; color: var(--text-2); white-space: nowrap; }
.repo-chip svg { width: 13px; height: 13px; color: var(--accent); flex-shrink: 0; }
.mi { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
.mi svg { width: 13px; height: 13px; }
.meta-sep { width: 3px; height: 3px; border-radius: 50%; background: #d7dae0; flex-shrink: 0; }
.task-right { display: flex; align-items: center; gap: 14px; flex-shrink: 0; }
.due { display: flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--text-2); font-weight: 500; white-space: nowrap; }
.due svg { width: 14px; height: 14px; color: var(--text-3); }
.dday { font-size: 11px; font-weight: 700; padding: 1px 7px; border-radius: 20px; white-space: nowrap; line-height: 1.5; }
.dday.urgent { color: var(--red); background: var(--red-weak); border: 1px solid var(--red-border); }
.dday.soon { color: var(--amber); background: var(--amber-weak); border: 1px solid var(--amber-border); }
.dday.calm { color: var(--text-2); background: #f1f2f4; border: 1px solid var(--border); }
.st-badge { font-size: 11.5px; font-weight: 600; padding: 3px 10px; border-radius: 20px; white-space: nowrap; min-width: 64px; text-align: center; }
.st-badge.prog { color: var(--blue); background: var(--blue-weak); }
.st-badge.review { color: var(--amber); background: var(--amber-weak); }
.st-badge.changes { color: var(--red); background: var(--red-weak); }
.st-badge.done { color: var(--green); background: var(--green-weak); }
.st-badge.todo { color: var(--text-2); background: #f1f2f4; }
</style>
</head>
<body>
<header class="topbar">
<div class="brand"><div class="logo">R</div>Relay</div>
<nav class="topnav">
<a href="#" class="active">내 업무</a>
<a href="저장소 목록.html">저장소</a>
</nav>
<div class="topbar-right">
<button class="icon-btn" title="검색"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg></button>
<button class="icon-btn" title="알림"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg></button>
<div class="me"></div>
</div>
</header>
<div class="page" data-screen-label="내 업무">
<div class="pagehead">
<h1>내 업무</h1>
<span class="sub">나에게 배정되었거나 내가 지시한 업무를 한곳에서 관리하세요.</span>
</div>
<!-- 담당 / 지시 토글 -->
<div class="viewtabs">
<a class="viewtab active" href="내 업무.html"><span class="tb-label">담당 업무</span> <span class="vt-count">6</span></a>
<a class="viewtab" href="내 업무 (지시한 업무).html"><span class="tb-label">지시한 업무</span> <span class="vt-count">5</span> <span class="vt-flag">승인 대기 1</span></a>
</div>
<!-- 툴바 -->
<div class="toolbar">
<div class="search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>
<input type="text" placeholder="업무 검색…">
</div>
<div class="repofilter">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
모든 저장소
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
</div>
<div class="sort">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5h10M11 9h7M11 13h4M3 17l3 3 3-3M6 18V4"/></svg>
마감 임박순
</div>
</div>
<!-- 수정 요청 -->
<div class="group">
<div class="group-head"><span class="gh-dot changes"></span><span class="gh-label">수정 요청</span><span class="gh-count">1</span></div>
<div class="list">
<a class="task-row" href="업무 상세 (수정 요청).html">
<span class="st-dot changes"></span>
<span class="task-main">
<span class="task-title"><span class="ttext">3분기 캠페인 예산안 재작성</span> <span class="tid">#14</span></span>
<span class="task-meta">
<span class="repo-chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>3분기 신제품 런칭 캠페인</span>
<span class="meta-sep"></span>
<span class="mi">지시 · 한승우</span>
</span>
</span>
<span class="task-right">
<span class="due"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>6월 16일 <span class="dday urgent">D-1</span></span>
<span class="st-badge changes">수정 요청</span>
</span>
</a>
</div>
</div>
<!-- 진행 중 -->
<div class="group">
<div class="group-head"><span class="gh-dot prog"></span><span class="gh-label">진행 중</span><span class="gh-count">2</span></div>
<div class="list">
<a class="task-row" href="업무 상세 (승인 요청).html">
<span class="st-dot prog"></span>
<span class="task-main">
<span class="task-title"><span class="ttext">브랜드 리뉴얼 킥오프 기획서 작성</span> <span class="tid">#12</span></span>
<span class="task-meta">
<span class="repo-chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>브랜드 리뉴얼 2026</span>
<span class="meta-sep"></span>
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>2/5</span>
<span class="meta-sep"></span>
<span class="mi">지시 · 한승우</span>
</span>
</span>
<span class="task-right">
<span class="due"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>6월 18일 <span class="dday soon">D-3</span></span>
<span class="st-badge prog">진행 중</span>
</span>
</a>
<a class="task-row" href="업무 상세 (승인 요청).html">
<span class="st-dot prog"></span>
<span class="task-main">
<span class="task-title"><span class="ttext">9월 신제품 GTM 전략 수립</span> <span class="tid">#10</span></span>
<span class="task-meta">
<span class="repo-chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>3분기 신제품 런칭 캠페인</span>
<span class="meta-sep"></span>
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>1/4</span>
<span class="meta-sep"></span>
<span class="mi">지시 · 한승우</span>
</span>
</span>
<span class="task-right">
<span class="due"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>6월 21일 <span class="dday calm">D-6</span></span>
<span class="st-badge prog">진행 중</span>
</span>
</a>
</div>
</div>
<!-- 할 일 -->
<div class="group">
<div class="group-head"><span class="gh-dot todo"></span><span class="gh-label">할 일</span><span class="gh-count">2</span></div>
<div class="list">
<a class="task-row" href="업무 상세 (승인 요청).html">
<span class="st-dot todo"></span>
<span class="task-main">
<span class="task-title"><span class="ttext">월간 뉴스레터 8월호 주제 선정</span> <span class="tid">#15</span></span>
<span class="task-meta">
<span class="repo-chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>월간 뉴스레터</span>
<span class="meta-sep"></span>
<span class="mi">지시 · 한승우</span>
</span>
</span>
<span class="task-right">
<span class="due"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>6월 24일 <span class="dday calm">D-9</span></span>
<span class="st-badge todo">할 일</span>
</span>
</a>
<a class="task-row" href="업무 상세 (승인 요청).html">
<span class="st-dot todo"></span>
<span class="task-main">
<span class="task-title"><span class="ttext">SEO 키워드 전략 분기 리뷰</span> <span class="tid">#13</span></span>
<span class="task-meta">
<span class="repo-chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>SEO 콘텐츠 허브</span>
<span class="meta-sep"></span>
<span class="mi">지시 · 한승우</span>
</span>
</span>
<span class="task-right">
<span class="due"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>6월 27일 <span class="dday calm">D-12</span></span>
<span class="st-badge todo">할 일</span>
</span>
</a>
</div>
</div>
<!-- 승인 대기 -->
<div class="group">
<div class="group-head"><span class="gh-dot review"></span><span class="gh-label">승인 대기</span><span class="gh-count">1</span></div>
<div class="list">
<a class="task-row" href="업무 상세 (승인 요청).html">
<span class="st-dot review"></span>
<span class="task-main">
<span class="task-title"><span class="ttext">브랜드 무드보드 방향 정리</span> <span class="tid">#2</span></span>
<span class="task-meta">
<span class="repo-chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>브랜드 리뉴얼 2026</span>
<span class="meta-sep"></span>
<span class="mi">한승우님 검토 중 · 1일 전 요청</span>
</span>
</span>
<span class="task-right">
<span class="due"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>6월 17일 <span class="dday soon">D-2</span></span>
<span class="st-badge review">승인 대기</span>
</span>
</a>
</div>
</div>
<!-- 완료 -->
<div class="group">
<div class="group-head"><span class="gh-dot done"></span><span class="gh-label">완료</span><span class="gh-count">2</span></div>
<div class="list">
<a class="task-row is-done" href="업무 상세 (승인 완료).html">
<span class="st-dot done"></span>
<span class="task-main">
<span class="task-title"><span class="ttext">2분기 캠페인 성과 회고 정리</span> <span class="tid">#6</span></span>
<span class="task-meta">
<span class="repo-chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>월간 뉴스레터</span>
<span class="meta-sep"></span>
<span class="mi">지시 · 한승우</span>
</span>
</span>
<span class="task-right">
<span class="due">6월 11일 승인됨</span>
<span class="st-badge done">완료</span>
</span>
</a>
<a class="task-row is-done" href="업무 상세 (승인 완료).html">
<span class="st-dot done"></span>
<span class="task-main">
<span class="task-title"><span class="ttext">신제품 네이밍 후보 정리</span> <span class="tid">#5</span></span>
<span class="task-meta">
<span class="repo-chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>3분기 신제품 런칭 캠페인</span>
<span class="meta-sep"></span>
<span class="mi">지시 · 한승우</span>
</span>
</span>
<span class="task-right">
<span class="due">6월 5일 승인됨</span>
<span class="st-badge done">완료</span>
</span>
</a>
</div>
</div>
</div>
</body>
</html>
+187
View File
@@ -0,0 +1,187 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>로그인 — Relay</title>
<link rel="preconnect" href="https://cdn.jsdelivr.net">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f4f5f7; --panel: #ffffff; --border: #e6e8ec; --border-strong: #d7dae0;
--text: #16181d; --text-2: #5b626e; --text-3: #9298a3;
--accent: #4f46e5; --accent-hover: #4338ca; --accent-weak: #eef0fe; --accent-border: #c7ccfb;
--kakao: #FEE500; --kakao-text: #191600;
--radius: 8px; --radius-sm: 5px;
}
html, body { height: 100%; }
body {
background: var(--bg); color: var(--text);
font-family: "Pretendard", -apple-system, system-ui, sans-serif;
font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased;
}
.auth { display: flex; min-height: 100vh; }
/* ---- brand panel ---- */
.brand-panel {
flex: 0 0 46%; max-width: 620px; position: relative; overflow: hidden;
background: linear-gradient(155deg, #4f46e5 0%, #4338ca 52%, #2e2a8f 100%);
color: #fff; padding: 44px 48px; display: flex; flex-direction: column;
}
.brand-panel .blob { position: absolute; border-radius: 50%; filter: blur(2px); pointer-events: none; }
.brand-panel .b1 { width: 360px; height: 360px; right: -120px; top: -90px; background: rgba(255,255,255,.10); }
.brand-panel .b2 { width: 260px; height: 260px; left: -90px; bottom: -70px; background: rgba(255,255,255,.07); }
.brand-panel .ring { position: absolute; right: 60px; bottom: 80px; width: 180px; height: 180px; border: 1.5px solid rgba(255,255,255,.14); border-radius: 50%; }
.brand-panel .ring::after { content: ""; position: absolute; inset: 34px; border: 1.5px solid rgba(255,255,255,.10); border-radius: 50%; }
.bp-logo { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 18px; letter-spacing: -.2px; position: relative; z-index: 1; }
.bp-logo .logo { width: 32px; height: 32px; border-radius: 9px; background: rgba(255,255,255,.16); border: 1px solid rgba(255,255,255,.22); display: grid; place-items: center; font-size: 17px; font-weight: 800; }
.bp-body { margin-top: auto; position: relative; z-index: 1; }
.bp-head { font-size: 34px; font-weight: 700; line-height: 1.32; letter-spacing: -.8px; text-wrap: balance; }
.bp-sub { margin-top: 18px; font-size: 15px; line-height: 1.7; color: rgba(255,255,255,.82); max-width: 400px; }
.bp-points { margin-top: 30px; display: flex; flex-direction: column; gap: 12px; }
.bp-point { display: flex; align-items: center; gap: 11px; font-size: 14px; color: rgba(255,255,255,.92); }
.bp-point .ic { width: 22px; height: 22px; border-radius: 50%; background: rgba(255,255,255,.16); display: grid; place-items: center; flex-shrink: 0; }
.bp-point .ic svg { width: 13px; height: 13px; }
.bp-foot { margin-top: 40px; font-size: 12.5px; color: rgba(255,255,255,.6); position: relative; z-index: 1; }
/* ---- form panel ---- */
.form-panel { flex: 1; display: flex; align-items: center; justify-content: center; padding: 40px 24px; }
.auth-card { width: 100%; max-width: 384px; }
.ac-logo { display: none; }
.ac-title { font-size: 24px; font-weight: 700; letter-spacing: -.5px; }
.ac-sub { color: var(--text-2); font-size: 14px; margin-top: 7px; }
.socials { display: flex; flex-direction: column; gap: 10px; margin-top: 28px; }
.sbtn {
height: 46px; border-radius: var(--radius); border: 1px solid var(--border-strong); background: #fff;
display: flex; align-items: center; justify-content: center; gap: 10px; cursor: pointer;
font-family: inherit; font-size: 14.5px; font-weight: 600; color: var(--text); text-decoration: none;
position: relative; transition: background .12s, border-color .12s;
}
.sbtn:hover { background: #f7f8fa; }
.sbtn .sico { position: absolute; left: 16px; display: grid; place-items: center; }
.sbtn.kakao { background: var(--kakao); border-color: var(--kakao); color: var(--kakao-text); }
.sbtn.kakao:hover { background: #f2d900; }
.divider { display: flex; align-items: center; gap: 14px; margin: 22px 0; color: var(--text-3); font-size: 12.5px; }
.divider::before, .divider::after { content: ""; height: 1px; background: var(--border); flex: 1; }
.field { margin-bottom: 14px; }
.flabel { font-size: 13px; font-weight: 600; color: var(--text-2); margin-bottom: 7px; display: block; }
.tinput { width: 100%; height: 44px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 13px; font-family: inherit; font-size: 14px; color: var(--text); background: #fff; }
.tinput:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
.tinput::placeholder { color: var(--text-3); }
.pw-wrap { position: relative; }
.pw-wrap .tinput { padding-right: 44px; }
.pw-toggle { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); width: 34px; height: 34px; border: none; background: transparent; color: var(--text-3); display: grid; place-items: center; cursor: pointer; border-radius: 6px; }
.pw-toggle:hover { background: #f1f2f4; color: var(--text-2); }
.pw-toggle svg { width: 18px; height: 18px; }
.options { display: flex; align-items: center; justify-content: space-between; margin: 4px 0 22px; }
.keep { display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 13px; color: var(--text-2); user-select: none; white-space: nowrap; }
.keep .box { width: 17px; height: 17px; border-radius: 5px; border: 1.5px solid var(--border-strong); display: grid; place-items: center; background: #fff; }
.keep .box.on { background: var(--accent); border-color: var(--accent); }
.keep .box svg { width: 11px; height: 11px; color: #fff; opacity: 0; }
.keep .box.on svg { opacity: 1; }
.link { color: var(--accent); font-size: 13px; font-weight: 600; text-decoration: none; white-space: nowrap; }
.link:hover { text-decoration: underline; }
.submit { width: 100%; height: 46px; border-radius: var(--radius); border: none; background: var(--accent); color: #fff; font-family: inherit; font-size: 15px; font-weight: 700; cursor: pointer; box-shadow: 0 1px 2px rgba(79,70,229,.4); text-decoration: none; display: flex; align-items: center; justify-content: center; }
.submit:hover { background: var(--accent-hover); }
.signup { margin-top: 24px; text-align: center; font-size: 13.5px; color: var(--text-2); }
.signup a { color: var(--accent); font-weight: 600; text-decoration: none; }
.signup a:hover { text-decoration: underline; }
.verify-note { margin-top: 6px; text-align: center; font-size: 12px; color: var(--text-3); display: flex; align-items: center; justify-content: center; gap: 5px; }
.verify-note svg { width: 13px; height: 13px; }
@media (max-width: 880px) {
.brand-panel { display: none; }
.ac-logo { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 17px; margin-bottom: 26px; }
.ac-logo .logo { width: 28px; height: 28px; border-radius: 8px; background: linear-gradient(135deg, #5b54f0, #4338ca); display: grid; place-items: center; color: #fff; font-size: 15px; font-weight: 800; }
}
</style>
</head>
<body>
<div class="auth">
<aside class="brand-panel">
<div class="blob b1"></div>
<div class="blob b2"></div>
<div class="ring"></div>
<div class="bp-logo"><span class="logo">R</span> Relay</div>
<div class="bp-body">
<div class="bp-head">지시부터 완료까지,<br>팀의 업무 흐름을 하나로</div>
<div class="bp-sub">저장소 단위로 업무를 정리하고, 담당자에게 명확하게 지시하세요. 진행 상황은 한눈에 추적됩니다.</div>
<div class="bp-points">
<div class="bp-point"><span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span> 저장소 · 업무 · 담당자로 이어지는 명확한 구조</div>
<div class="bp-point"><span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span> 체크리스트와 마감기한으로 진행 관리</div>
<div class="bp-point"><span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span> Gitea 연동으로 안전하게</div>
</div>
</div>
<div class="bp-foot">© 2026 Relay · 사내 업무 지시 플랫폼</div>
</aside>
<main class="form-panel">
<div class="auth-card">
<div class="ac-logo"><span class="logo">R</span> Relay</div>
<h1 class="ac-title">로그인</h1>
<p class="ac-sub">업무 지시를 시작하려면 로그인하세요.</p>
<!-- 소셜 로그인 -->
<div class="socials">
<a class="sbtn" href="저장소 목록.html">
<span class="sico"><svg width="20" height="20" viewBox="0 0 48 48"><path fill="#FFC107" d="M43.6 20.5H42V20H24v8h11.3C33.7 32.4 29.2 36 24 36c-6.6 0-12-5.4-12-12s5.4-12 12-12c3.1 0 5.9 1.2 8 3.1l5.7-5.7C34.5 6.5 29.5 4 24 4 12.9 4 4 12.9 4 24s8.9 20 20 20 20-8.9 20-20c0-1.3-.1-2.3-.4-3.5z"/><path fill="#FF3D00" d="M6.3 14.7l6.6 4.8C14.7 15.1 19 12 24 12c3.1 0 5.9 1.2 8 3.1l5.7-5.7C34.5 6.5 29.5 4 24 4 16.3 4 9.7 8.3 6.3 14.7z"/><path fill="#4CAF50" d="M24 44c5.4 0 10.3-2.1 14-5.4l-6.5-5.5C29.6 34.6 26.9 36 24 36c-5.2 0-9.6-3.5-11.2-8.3l-6.5 5C9.6 39.6 16.2 44 24 44z"/><path fill="#1976D2" d="M43.6 20.5H42V20H24v8h11.3c-.8 2.2-2.2 4.1-4.1 5.5l6.5 5.5C41.4 36 44 30.5 44 24c0-1.3-.1-2.3-.4-3.5z"/></svg></span>
Google로 계속하기
</a>
<a class="sbtn kakao" href="저장소 목록.html">
<span class="sico"><svg width="19" height="19" viewBox="0 0 24 24" fill="#191600"><path d="M12 3C6.5 3 2 6.5 2 10.8c0 2.8 1.9 5.2 4.7 6.6-.2.7-.7 2.6-.8 3-.1.5.2.5.4.4.2-.1 2.6-1.8 3.7-2.5.6.1 1.3.1 2 .1 5.5 0 10-3.5 10-7.8C22 6.5 17.5 3 12 3z"/></svg></span>
카카오로 계속하기
</a>
</div>
<div class="divider">또는 이메일로 로그인</div>
<!-- 이메일 로그인 -->
<div class="field">
<label class="flabel">이메일</label>
<input class="tinput" type="email" placeholder="name@acme.co" value="">
</div>
<div class="field">
<label class="flabel">비밀번호</label>
<div class="pw-wrap">
<input class="tinput" type="password" placeholder="비밀번호 입력" value="">
<button class="pw-toggle" type="button" title="비밀번호 표시"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
</div>
</div>
<div class="options">
<span class="keep"><span class="box on"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span> 로그인 상태 유지</span>
<a class="link" href="#">비밀번호 찾기</a>
</div>
<a class="submit" href="저장소 목록.html">로그인</a>
<div class="signup">계정이 없으신가요? <a href="회원가입.html">회원가입</a></div>
<div class="verify-note">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16v16H4z" opacity="0"/><path d="M22 6 12 13 2 6"/><rect x="2" y="4" width="20" height="16" rx="2"/></svg>
이메일 회원가입은 인증 메일 확인 후 완료됩니다
</div>
</div>
</main>
</div>
<script>
// 비밀번호 표시 토글 (정적 시안용 최소 인터랙션)
document.querySelector('.pw-toggle').addEventListener('click', function () {
var input = this.parentElement.querySelector('input');
input.type = input.type === 'password' ? 'text' : 'password';
});
document.querySelector('.keep').addEventListener('click', function () {
this.querySelector('.box').classList.toggle('on');
});
</script>
</body>
</html>
@@ -0,0 +1,469 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>메인 배너 이미지 2종 디자인 — Relay</title>
<link rel="preconnect" href="https://cdn.jsdelivr.net">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f4f5f7; --panel: #ffffff; --border: #e6e8ec; --border-strong: #d7dae0;
--text: #16181d; --text-2: #5b626e; --text-3: #9298a3;
--accent: #4f46e5; --accent-hover: #4338ca; --accent-weak: #eef0fe; --accent-border: #c7ccfb;
--green: #1f9d57; --green-weak: #e8f6ee; --blue: #1d4ed8; --blue-weak: #e8efff; --blue-border: #c9dbff;
--red: #d6455d; --red-weak: #fdeef0; --red-border: #f1c0c8; --amber: #b7791f; --amber-weak: #fdf4e3; --amber-border: #f3e2bd;
--radius: 6px; --radius-sm: 4px; --shadow-sm: 0 1px 2px rgba(20,24,33,.05);
}
html, body { background: var(--bg); color: var(--text); font-family: "Pretendard", -apple-system, system-ui, sans-serif; font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
body { min-height: 100vh; }
/* topbar */
.topbar { height: 52px; background: var(--panel); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 28px; padding: 0 20px; position: sticky; top: 0; z-index: 50; }
.brand { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 15px; letter-spacing: -.2px; }
.brand .logo { width: 26px; height: 26px; border-radius: 7px; background: linear-gradient(135deg, #5b54f0, #4338ca); display: grid; place-items: center; color: #fff; font-size: 15px; font-weight: 800; box-shadow: 0 2px 6px rgba(79,70,229,.35); }
.topnav { display: flex; align-items: center; gap: 2px; }
.topnav a { color: var(--text-2); text-decoration: none; font-size: 13.5px; font-weight: 500; padding: 7px 12px; border-radius: var(--radius-sm); line-height: 1; white-space: nowrap; }
.topnav a:hover { background: #f1f2f4; color: var(--text); }
.topnav a.active { color: var(--accent); background: var(--accent-weak); font-weight: 600; }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.icon-btn { width: 32px; height: 32px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-2); display: grid; place-items: center; cursor: pointer; }
.icon-btn:hover { background: #f1f2f4; color: var(--text); }
.me { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-size: 11.5px; font-weight: 700; color: #fff; background: #0ea5a3; margin-left: 4px; }
/* page */
.page { max-width: 1080px; margin: 0 auto; padding: 0 24px 60px; }
.crumb { display: flex; align-items: center; gap: 7px; color: var(--text-3); font-size: 12.5px; padding: 16px 0 0; white-space: nowrap; }
.crumb .lnk { color: var(--text-2); font-weight: 600; text-decoration: none; }
.crumb .lnk:hover { color: var(--accent); }
.crumb .sep { opacity: .5; }
.crumb b { color: var(--text-2); font-weight: 600; }
.pagehead { display: flex; align-items: flex-start; gap: 14px; padding: 12px 0 18px; }
.ph-main { flex: 1; min-width: 0; }
.ph-titlerow { display: flex; align-items: center; gap: 10px; }
.st-badge { font-size: 11.5px; font-weight: 600; padding: 3px 10px; border-radius: 20px; white-space: nowrap; }
.st-badge.prog { color: var(--blue); background: var(--blue-weak); border: 1px solid var(--blue-border); }
.st-badge.review { color: var(--amber); background: var(--amber-weak); border: 1px solid var(--amber-border); }
.st-badge.done { color: var(--green); background: var(--green-weak); border: 1px solid #cde8d8; }
.st-badge.changes { color: var(--red); background: var(--red-weak); border: 1px solid var(--red-border); }
.ph-tid { font-size: 14px; font-weight: 600; color: var(--text-3); }
h1.ph-title { font-size: 23px; font-weight: 700; letter-spacing: -.5px; margin-top: 8px; line-height: 1.3; }
.pagehead .actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; padding-top: 2px; }
.btn { height: 34px; padding: 0 14px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; border: 1px solid var(--border-strong); background: var(--panel); color: var(--text-2); cursor: pointer; display: inline-flex; align-items: center; gap: 6px; line-height: 1; text-decoration: none; white-space: nowrap; }
.btn:hover { background: #f7f8fa; color: var(--text); }
.btn svg { width: 15px; height: 15px; }
.btn.icon { width: 34px; padding: 0; justify-content: center; }
/* more menu */
.more-wrap { position: relative; }
.menu { display: none; position: absolute; top: calc(100% + 6px); right: 0; z-index: 40; width: 168px; background: #fff; border: 1px solid var(--border); border-radius: 9px; box-shadow: 0 12px 32px rgba(20,24,33,.16); padding: 5px; }
.menu.open { display: block; }
.menu-item { display: flex; align-items: center; gap: 10px; width: 100%; border: none; background: transparent; font-family: inherit; font-size: 13px; font-weight: 500; color: var(--text); padding: 8px 9px; border-radius: var(--radius-sm); cursor: pointer; text-align: left; white-space: nowrap; }
.menu-item:hover { background: #f5f6f8; }
.menu-item svg { width: 16px; height: 16px; color: var(--text-3); flex-shrink: 0; }
.menu-item.danger { color: var(--red); }
.menu-item.danger svg { color: var(--red); }
.menu-item.danger:hover { background: var(--red-weak); }
.menu-sep { height: 1px; background: var(--border); margin: 5px 0; }
/* modal */
.modal-backdrop { position: fixed; inset: 0; background: rgba(20,24,33,.5); display: none; align-items: center; justify-content: center; z-index: 100; padding: 24px; }
.modal-backdrop.open { display: flex; }
.modal { width: 100%; max-width: 440px; background: #fff; border-radius: 14px; box-shadow: 0 24px 64px rgba(20,24,33,.32); overflow: hidden; }
.modal-body { padding: 26px 24px 8px; text-align: center; }
.modal-ico { width: 46px; height: 46px; border-radius: 50%; background: var(--red-weak); color: var(--red); display: grid; place-items: center; margin: 0 auto 14px; }
.modal-ico svg { width: 22px; height: 22px; }
.modal-title { font-size: 17px; font-weight: 700; letter-spacing: -.3px; }
.modal-desc { font-size: 13px; color: var(--text-2); line-height: 1.6; margin-top: 8px; }
.modal-desc b { color: var(--text); font-weight: 600; }
.modal-foot { display: flex; gap: 9px; padding: 18px 24px 20px; }
.mbtn { flex: 1; height: 40px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; border: 1px solid var(--border-strong); background: #fff; color: var(--text-2); cursor: pointer; }
.mbtn:hover { background: #f1f2f4; color: var(--text); }
.mbtn.danger { background: var(--red); border-color: var(--red); color: #fff; box-shadow: 0 1px 2px rgba(214,69,93,.4); }
.mbtn.danger:hover { background: #c23a51; }
.mbtn.approve { background: var(--green); border-color: var(--green); color: #fff; box-shadow: 0 1px 2px rgba(31,157,87,.4); }
.mbtn.approve:hover { background: #188a4b; }
.mbtn.request-send { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
.mbtn.request-send:hover { background: var(--accent-hover); }
.modal-textarea { width: 100%; min-height: 92px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 10px 12px; font-family: inherit; font-size: 13.5px; line-height: 1.6; color: var(--text); resize: vertical; }
.modal-textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
.modal-textarea::placeholder { color: var(--text-3); }
/* approval sidebar card */
.ap-card { padding: 16px 18px 18px; border-width: 1px; border-style: solid; }
.ap-card.review { border-color: var(--amber-border); background: var(--amber-weak); }
.ap-card.request { border-color: var(--accent-border); background: var(--accent-weak); }
.ap-card.changes { border-color: var(--red-border); background: var(--red-weak); }
.ap-card.changes .ap-card-ico { color: var(--red); border-color: var(--red-border); }
.ap-card.changes .ap-card-title { color: var(--red); }
.ap-card.changes .ap-card-note { border-color: var(--red-border); }
.ap-card-head { display: flex; align-items: center; gap: 9px; }
.ap-card-ico { width: 30px; height: 30px; border-radius: 8px; background: #fff; display: grid; place-items: center; flex-shrink: 0; border: 1px solid; }
.ap-card.review .ap-card-ico { color: var(--amber); border-color: var(--amber-border); }
.ap-card.request .ap-card-ico { color: var(--accent); border-color: var(--accent-border); }
.ap-card-ico svg { width: 17px; height: 17px; }
.ap-card-title { font-size: 14px; font-weight: 700; white-space: nowrap; }
.ap-card.review .ap-card-title { color: #8a5a12; }
.ap-card.request .ap-card-title { color: var(--accent-hover); }
.ap-card-meta { font-size: 12px; color: var(--text-2); margin-top: 10px; line-height: 1.5; }
.ap-card-meta b { color: var(--text); font-weight: 600; }
.ap-card-desc { font-size: 12.5px; color: var(--text-2); margin-top: 10px; line-height: 1.55; }
.ap-card-note { font-size: 12.5px; color: var(--text); background: #fff; border: 1px solid var(--amber-border); border-radius: 8px; padding: 9px 11px; margin-top: 10px; line-height: 1.5; }
.ap-card-actions { display: flex; flex-direction: column; gap: 8px; margin-top: 13px; }
.ap-btn { height: 38px; padding: 0 14px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; cursor: pointer; border: 1px solid var(--border-strong); background: #fff; color: var(--text-2); display: inline-flex; align-items: center; justify-content: center; gap: 6px; }
.ap-btn svg { width: 15px; height: 15px; }
.ap-btn.reject:hover { background: #fdeef0; border-color: var(--red-border); color: var(--red); }
.ap-btn.approve { background: var(--green); border-color: var(--green); color: #fff; box-shadow: 0 1px 2px rgba(31,157,87,.4); }
.ap-btn.approve:hover { background: #188a4b; }
.ap-btn.request-send { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
.ap-btn.request-send:hover { background: var(--accent-hover); }
/* approval system event in thread */
.sys-event { display: flex; align-items: center; gap: 10px; padding: 12px 0; border-top: 1px solid var(--border); font-size: 13px; color: var(--text-2); }
.sys-event .se-ico { width: 26px; height: 26px; border-radius: 50%; background: var(--amber-weak); color: var(--amber); display: grid; place-items: center; flex-shrink: 0; }
.sys-event .se-ico svg { width: 14px; height: 14px; }
.sys-event b { color: var(--text); font-weight: 600; }
.sys-event .se-time { margin-left: auto; font-size: 12px; color: var(--text-3); white-space: nowrap; }
/* layout */
.layout { display: grid; grid-template-columns: 1fr 320px; gap: 20px; align-items: start; }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow-sm); }
.main-card { padding: 22px 26px 26px; }
.sect + .sect { margin-top: 26px; padding-top: 22px; border-top: 1px solid var(--border); }
.sect-label { display: flex; align-items: center; gap: 8px; font-size: 12.5px; font-weight: 600; color: var(--text-2); margin-bottom: 12px; white-space: nowrap; }
.sect-label .pr { margin-left: auto; display: flex; align-items: center; gap: 9px; }
.progress-bar { width: 92px; height: 6px; border-radius: 4px; background: #eceef1; overflow: hidden; }
.progress-bar > i { display: block; height: 100%; background: var(--green); border-radius: 4px; }
.progress-txt { font-size: 12px; font-weight: 600; color: var(--text-2); white-space: nowrap; }
.content { font-size: 14px; color: var(--text); line-height: 1.7; }
.content p { margin-bottom: 11px; }
.content p:last-child { margin-bottom: 0; }
.content .mention { color: var(--accent); font-weight: 600; background: var(--accent-weak); padding: 0 3px; border-radius: 3px; }
/* checklist */
.checklist { display: flex; flex-direction: column; }
.citem { display: flex; align-items: center; gap: 11px; padding: 8px 0; border-radius: var(--radius-sm); }
.cbox { width: 18px; height: 18px; border-radius: 5px; border: 1.5px solid var(--border-strong); flex-shrink: 0; display: grid; place-items: center; background: #fff; }
.cbox.done { background: var(--green); border-color: var(--green); }
.cbox svg { width: 12px; height: 12px; color: #fff; opacity: 0; }
.cbox.done svg { opacity: 1; }
.citem .ctext { font-size: 13.5px; color: var(--text); white-space: nowrap; }
.citem.done .ctext { color: var(--text-3); text-decoration: line-through; }
/* nested subtasks (read-only) */
.task-group + .task-group { margin-top: 11px; padding-top: 11px; border-top: 1px solid var(--border); }
.citem.parent .ctext { font-weight: 600; }
.sub-count { margin-left: auto; font-size: 11px; font-weight: 600; color: var(--text-2); background: #f1f2f4; border: 1px solid var(--border); border-radius: 20px; padding: 1px 8px; line-height: 1.55; white-space: nowrap; }
.citem.parent.done .sub-count { color: var(--green); background: var(--green-weak); border-color: #cde8d8; }
.subtasks { margin-left: 29px; padding-left: 14px; border-left: 1.5px solid var(--border); display: flex; flex-direction: column; }
.subtask { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-radius: var(--radius-sm); }
.cbox.sm { width: 16px; height: 16px; border-radius: 5px; }
.cbox.sm svg { width: 11px; height: 11px; }
.subtask .ctext { font-size: 13px; color: var(--text-2); white-space: nowrap; }
.subtask.done .ctext { color: var(--text-3); text-decoration: line-through; }
/* comments */
.comment { display: flex; gap: 11px; padding: 14px 0; border-top: 1px solid var(--border); }
.comment:first-of-type { border-top: none; }
.c-av { width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; font-size: 12px; font-weight: 700; color: #fff; flex-shrink: 0; }
.c-body { flex: 1; min-width: 0; }
.c-head { display: flex; align-items: center; gap: 8px; }
.c-name { font-size: 13.5px; font-weight: 600; white-space: nowrap; }
.c-role { font-size: 11px; font-weight: 600; color: var(--accent); background: var(--accent-weak); border-radius: 20px; padding: 1px 7px; white-space: nowrap; }
.c-time { font-size: 12px; color: var(--text-3); white-space: nowrap; }
.c-text { font-size: 13.5px; color: var(--text); line-height: 1.6; margin-top: 4px; }
.c-file { display: inline-flex; align-items: center; gap: 7px; margin-top: 8px; padding: 6px 10px; border: 1px solid var(--border); border-radius: var(--radius); font-size: 12.5px; font-weight: 600; color: var(--text-2); }
.c-file .fi { width: 22px; height: 22px; border-radius: 5px; background: #e25950; color: #fff; display: grid; place-items: center; font-size: 8px; font-weight: 800; }
.composer { display: flex; gap: 11px; margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); }
.composer .field { flex: 1; }
.composer textarea { width: 100%; min-height: 64px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 10px 12px; font-family: inherit; font-size: 13.5px; resize: vertical; color: var(--text); }
.composer textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
.composer textarea::placeholder { color: var(--text-3); }
.composer .crow { display: flex; justify-content: flex-end; margin-top: 9px; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
.btn.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
/* sidebar */
.side { display: flex; flex-direction: column; gap: 16px; }
.side-card { padding: 16px 18px 18px; }
.side-field + .side-field { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); }
.side-label { font-size: 12px; font-weight: 600; color: var(--text-3); margin-bottom: 9px; white-space: nowrap; }
.status-badge { display: inline-flex; align-items: center; gap: 8px; height: 30px; padding: 0 13px; border: 1px solid var(--blue-border); background: var(--blue-weak); border-radius: 20px; font-size: 13px; font-weight: 600; color: var(--blue); white-space: nowrap; }
.status-badge .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--blue); }
.status-badge.review { color: var(--amber); background: var(--amber-weak); border-color: var(--amber-border); }
.status-badge.review .dot { background: var(--amber); }
.status-badge.changes { color: var(--red); background: var(--red-weak); border-color: var(--red-border); }
.status-badge.changes .dot { background: var(--red); }
.assignees { display: flex; flex-direction: column; gap: 9px; }
.ass { display: flex; align-items: center; gap: 9px; }
.avatar { width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center; font-size: 11px; font-weight: 700; color: #fff; flex-shrink: 0; }
.av-a { background: #e0518d; } .av-b { background: #3d8bf2; } .av-c { background: #16a37b; } .av-d { background: #b3631f; } .av-f { background: #d0982a; }
.ass .nm { font-size: 13px; font-weight: 600; white-space: nowrap; }
.ass .rl { font-size: 11.5px; color: var(--text-3); white-space: nowrap; }
.dl { display: flex; align-items: center; gap: 9px; font-size: 13.5px; font-weight: 600; color: var(--text); white-space: nowrap; }
.dl svg { width: 16px; height: 16px; color: var(--text-3); }
.dl .dday { font-size: 11.5px; font-weight: 700; color: var(--amber); background: #fdf4e3; border: 1px solid #f3e2bd; border-radius: 20px; padding: 1px 7px; margin-left: auto; white-space: nowrap; }
.files { display: flex; flex-direction: column; gap: 7px; }
.file { display: flex; align-items: center; gap: 10px; padding: 8px 9px; border: 1px solid var(--border); border-radius: var(--radius); }
.file-ico { width: 28px; height: 28px; border-radius: 6px; display: grid; place-items: center; flex-shrink: 0; font-size: 9px; font-weight: 800; color: #fff; }
.fi-img { background: #2aa775; } .fi-zip { background: #8a64d6; }
.file-info { min-width: 0; flex: 1; }
.file-name { font-size: 12.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; }
.file-size { font-size: 11.5px; color: var(--text-3); display: block; }
.issuer { display: flex; align-items: center; gap: 10px; }
.issuer .nm { font-size: 13px; font-weight: 600; white-space: nowrap; }
.issuer .rl { font-size: 11.5px; color: var(--text-3); white-space: nowrap; }
.meta-line { font-size: 12px; color: var(--text-3); margin-top: 4px; white-space: nowrap; }
</style>
</head>
<body>
<header class="topbar">
<div class="brand"><div class="logo">R</div>Relay</div>
<nav class="topnav">
<a href="내 업무.html">내 업무</a>
<a href="저장소 목록.html" class="active">저장소</a>
</nav>
<div class="topbar-right">
<button class="icon-btn" title="검색"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg></button>
<button class="icon-btn" title="알림"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg></button>
<div class="me" style="background:#3d8bf2"></div>
</div>
</header>
<div class="page" data-screen-label="업무 상세">
<div class="crumb">
<a href="저장소 목록.html" class="lnk">저장소</a>
<span class="sep">/</span>
<a href="저장소 상세.html" class="lnk">3분기 신제품 런칭 캠페인</a>
<span class="sep">/</span>
<a href="저장소 상세.html" class="lnk">업무</a>
<span class="sep">/</span>
<b>#9</b>
</div>
<div class="pagehead">
<div class="ph-main">
<div class="ph-titlerow">
<span class="st-badge changes">수정 요청</span>
<span class="ph-tid">#9</span>
</div>
<h1 class="ph-title">메인 배너 이미지 2종 (가로/세로) 디자인</h1>
</div>
<div class="actions">
<a class="btn" href="업무 작성.html"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>수정</a>
<div class="more-wrap">
<button class="btn icon" id="moreBtn" type="button" title="더보기"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg></button>
<div class="menu" id="moreMenu">
<button class="menu-item" type="button">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
업무 복제
</button>
<button class="menu-item" type="button">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/></svg>
링크 복사
</button>
<div class="menu-sep"></div>
<button class="menu-item danger" id="deleteOpen" type="button">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg>
업무 삭제
</button>
</div>
</div>
</div>
</div>
<div class="layout">
<!-- main -->
<section class="card main-card">
<div class="sect">
<div class="sect-label">업무 내용</div>
<div class="content">
<p>9월 신제품 런칭 캠페인에 사용할 메인 배너를 가로형/세로형 2종으로 제작합니다. 톤앤매너는 <span class="mention">@브랜드_가이드라인.pdf</span> 기준을 따라주세요.</p>
<p>퍼포먼스 광고와 자사몰 상단에 동시 노출되므로, 두 비율 모두 핵심 메시지("민감 피부 저자극")가 명확히 보이도록 구성해주세요. 1차 시안 확정 후 모바일 적응형까지 진행합니다.</p>
</div>
</div>
<div class="sect">
<div class="sect-label">해야 할 일
<span class="pr"><span class="progress-bar"><i style="width:33%"></i></span><span class="progress-txt">1 / 3 완료</span></span>
</div>
<div class="checklist">
<div class="citem done">
<span class="cbox done"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
<span class="ctext">가로형 메인 배너 1920×1080</span>
</div>
<div class="citem">
<span class="cbox"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
<span class="ctext">세로형 메인 배너 1080×1920</span>
</div>
<div class="citem">
<span class="cbox"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
<span class="ctext">모바일 적응형 600×600</span>
</div>
</div>
</div>
<div class="sect">
<div class="sect-label">댓글 <span style="color:var(--text-3);font-weight:500">3</span></div>
<div class="comment">
<span class="c-av av-f"></span>
<div class="c-body">
<div class="c-head"><span class="c-name">정태경</span><span class="c-role">지시자</span><span class="c-time">2일 전</span></div>
<div class="c-text">가로형부터 확정하고 세로형 진행해주세요. 카피 위치는 좌측 정렬 기준입니다.</div>
</div>
</div>
<div class="comment">
<span class="c-av av-b"></span>
<div class="c-body">
<div class="c-head"><span class="c-name">박지훈</span><span class="c-time">1일 전</span></div>
<div class="c-text">가로형 1차 시안 업로드했습니다. 검토 부탁드려요.</div>
<div class="c-file"><span class="fi">IMG</span>main_banner_h_v1.png</div>
</div>
</div>
<div class="comment">
<span class="c-av av-d"></span>
<div class="c-body">
<div class="c-head"><span class="c-name">최유진</span><span class="c-time">3시간 전</span></div>
<div class="c-text">세로형은 내일 오전까지 공유하겠습니다.</div>
</div>
</div>
<div class="sys-event">
<span class="se-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg></span>
<span><b>박지훈</b>님이 승인을 요청했습니다</span>
<span class="se-time">2시간 전</span>
</div>
<div class="composer">
<span class="c-av" style="background:#3d8bf2"></span>
<div class="field">
<textarea placeholder="댓글을 입력하세요…"></textarea>
<div class="crow"><button class="btn primary" type="button">댓글 등록</button></div>
</div>
</div>
</div>
</section>
<!-- sidebar -->
<aside class="side">
<!-- 수정 요청됨: 작업자(박지훈) 시점 -->
<div class="card ap-card changes">
<div class="ap-card-head">
<span class="ap-card-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/></svg></span>
<span class="ap-card-title">수정 요청됨</span>
</div>
<div class="ap-card-meta"><b>정태경</b>님이 수정을 요청했습니다 · 1시간 전</div>
<div class="ap-card-note">세로형 카피 가독성이 낮으니 자간과 대비를 조정해주세요. 모바일 적응형까지 포함해 다시 올려주세요.</div>
<div class="ap-card-actions">
<button class="ap-btn request-send" id="requestOpen" type="button"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/></svg>다시 승인 요청</button>
</div>
</div>
<div class="card side-card">
<div class="side-field">
<div class="side-label">상태</div>
<span class="status-badge changes"><span class="dot"></span>수정 요청</span>
</div>
<div class="side-field">
<div class="side-label">담당자 · 2명</div>
<div class="assignees">
<div class="ass"><span class="avatar av-b"></span><span><div class="nm">박지훈</div><div class="rl">퍼포먼스 마케팅</div></span></div>
<div class="ass"><span class="avatar av-d"></span><span><div class="nm">최유진</div><div class="rl">UX 디자인</div></span></div>
</div>
</div>
<div class="side-field">
<div class="side-label">마감기한</div>
<div class="dl">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>
6월 18일 (목)
<span class="dday">D-3</span>
</div>
</div>
</div>
<div class="card side-card">
<div class="side-field">
<div class="side-label">첨부파일 · 2개</div>
<div class="files">
<div class="file"><span class="file-ico fi-img">IMG</span><span class="file-info"><span class="file-name">레퍼런스_무드보드.png</span><span class="file-size">3.2 MB</span></span></div>
<div class="file"><span class="file-ico fi-zip">ZIP</span><span class="file-info"><span class="file-name">배너_소스_파일.zip</span><span class="file-size">18.4 MB</span></span></div>
</div>
</div>
</div>
<div class="card side-card">
<div class="side-field">
<div class="side-label">지시자</div>
<div class="issuer"><span class="avatar av-f"></span><span><div class="nm">정태경</div><div class="rl">마케팅 그룹 리드</div></span></div>
<div class="meta-line">6월 8일 지시 · #9</div>
</div>
</div>
</aside>
</div>
</div>
<!-- 삭제 확인 모달 -->
<div class="modal-backdrop" id="deleteModal">
<div class="modal" role="dialog" aria-modal="true">
<div class="modal-body">
<div class="modal-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg></div>
<h2 class="modal-title">업무를 삭제할까요?</h2>
<p class="modal-desc"><b>#9 메인 배너 이미지 2종 (가로/세로) 디자인</b> 업무와 체크리스트·댓글·첨부파일이 영구적으로 삭제됩니다. 이 작업은 되돌릴 수 없습니다.</p>
</div>
<div class="modal-foot">
<button class="mbtn" id="deleteCancel" type="button">취소</button>
<button class="mbtn danger" type="button">삭제</button>
</div>
</div>
</div>
<!-- 승인 요청 모달 -->
<div class="modal-backdrop" id="requestModal">
<div class="modal" role="dialog" aria-modal="true">
<div class="modal-body" style="text-align:left;padding:22px 24px 8px">
<h2 class="modal-title">승인 요청 보내기</h2>
<p class="modal-desc" style="margin:8px 0 14px">지시자 <b>정태경</b>님에게 검토 승인을 요청합니다. 완료한 작업과 전달할 내용을 간단히 적어주세요. 업무가 <b>승인 대기</b> 상태로 변경됩니다.</p>
<textarea class="modal-textarea" placeholder="예) 가로형/세로형 메인 배너 1차 제작을 완료했습니다. 검토 부탁드립니다."></textarea>
</div>
<div class="modal-foot">
<button class="mbtn" data-close="requestModal" type="button">취소</button>
<button class="mbtn request-send" type="button">요청 보내기</button>
</div>
</div>
</div>
<script>
var moreBtn = document.getElementById('moreBtn');
var moreMenu = document.getElementById('moreMenu');
moreBtn.addEventListener('click', function (e) { e.stopPropagation(); moreMenu.classList.toggle('open'); });
document.addEventListener('click', function () { moreMenu.classList.remove('open'); });
moreMenu.addEventListener('click', function (e) { e.stopPropagation(); });
/* 삭제 모달 */
var dModal = document.getElementById('deleteModal');
document.getElementById('deleteOpen').addEventListener('click', function () { moreMenu.classList.remove('open'); dModal.classList.add('open'); });
document.getElementById('deleteCancel').addEventListener('click', function () { dModal.classList.remove('open'); });
dModal.addEventListener('click', function (e) { if (e.target === dModal) dModal.classList.remove('open'); });
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') dModal.classList.remove('open'); });
/* 승인 요청 모달 */
function openModal(id) { document.getElementById(id).classList.add('open'); }
function closeModal(el) { el.classList.remove('open'); }
document.getElementById('requestOpen').addEventListener('click', function () { openModal('requestModal'); });
document.querySelectorAll('[data-close]').forEach(function (b) {
b.addEventListener('click', function () { closeModal(document.getElementById(b.getAttribute('data-close'))); });
});
var reqModal = document.getElementById('requestModal');
reqModal.addEventListener('click', function (e) { if (e.target === reqModal) closeModal(reqModal); });
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeModal(reqModal); });
</script>
</body>
</html>
@@ -0,0 +1,463 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>메인 배너 이미지 2종 디자인 — Relay</title>
<link rel="preconnect" href="https://cdn.jsdelivr.net">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f4f5f7; --panel: #ffffff; --border: #e6e8ec; --border-strong: #d7dae0;
--text: #16181d; --text-2: #5b626e; --text-3: #9298a3;
--accent: #4f46e5; --accent-hover: #4338ca; --accent-weak: #eef0fe; --accent-border: #c7ccfb;
--green: #1f9d57; --green-weak: #e8f6ee; --blue: #1d4ed8; --blue-weak: #e8efff; --blue-border: #c9dbff;
--red: #d6455d; --red-weak: #fdeef0; --red-border: #f1c0c8; --amber: #b7791f; --amber-weak: #fdf4e3; --amber-border: #f3e2bd;
--radius: 6px; --radius-sm: 4px; --shadow-sm: 0 1px 2px rgba(20,24,33,.05);
}
html, body { background: var(--bg); color: var(--text); font-family: "Pretendard", -apple-system, system-ui, sans-serif; font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
body { min-height: 100vh; }
/* topbar */
.topbar { height: 52px; background: var(--panel); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 28px; padding: 0 20px; position: sticky; top: 0; z-index: 50; }
.brand { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 15px; letter-spacing: -.2px; }
.brand .logo { width: 26px; height: 26px; border-radius: 7px; background: linear-gradient(135deg, #5b54f0, #4338ca); display: grid; place-items: center; color: #fff; font-size: 15px; font-weight: 800; box-shadow: 0 2px 6px rgba(79,70,229,.35); }
.topnav { display: flex; align-items: center; gap: 2px; }
.topnav a { color: var(--text-2); text-decoration: none; font-size: 13.5px; font-weight: 500; padding: 7px 12px; border-radius: var(--radius-sm); line-height: 1; white-space: nowrap; }
.topnav a:hover { background: #f1f2f4; color: var(--text); }
.topnav a.active { color: var(--accent); background: var(--accent-weak); font-weight: 600; }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.icon-btn { width: 32px; height: 32px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-2); display: grid; place-items: center; cursor: pointer; }
.icon-btn:hover { background: #f1f2f4; color: var(--text); }
.me { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-size: 11.5px; font-weight: 700; color: #fff; background: #0ea5a3; margin-left: 4px; }
/* page */
.page { max-width: 1080px; margin: 0 auto; padding: 0 24px 60px; }
.crumb { display: flex; align-items: center; gap: 7px; color: var(--text-3); font-size: 12.5px; padding: 16px 0 0; white-space: nowrap; }
.crumb .lnk { color: var(--text-2); font-weight: 600; text-decoration: none; }
.crumb .lnk:hover { color: var(--accent); }
.crumb .sep { opacity: .5; }
.crumb b { color: var(--text-2); font-weight: 600; }
.pagehead { display: flex; align-items: flex-start; gap: 14px; padding: 12px 0 18px; }
.ph-main { flex: 1; min-width: 0; }
.ph-titlerow { display: flex; align-items: center; gap: 10px; }
.st-badge { font-size: 11.5px; font-weight: 600; padding: 3px 10px; border-radius: 20px; white-space: nowrap; }
.st-badge.prog { color: var(--blue); background: var(--blue-weak); border: 1px solid var(--blue-border); }
.st-badge.review { color: var(--amber); background: var(--amber-weak); border: 1px solid var(--amber-border); }
.st-badge.done { color: var(--green); background: var(--green-weak); border: 1px solid #cde8d8; }
.st-badge.changes { color: var(--red); background: var(--red-weak); border: 1px solid var(--red-border); }
.ph-tid { font-size: 14px; font-weight: 600; color: var(--text-3); }
h1.ph-title { font-size: 23px; font-weight: 700; letter-spacing: -.5px; margin-top: 8px; line-height: 1.3; }
.pagehead .actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; padding-top: 2px; }
.btn { height: 34px; padding: 0 14px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; border: 1px solid var(--border-strong); background: var(--panel); color: var(--text-2); cursor: pointer; display: inline-flex; align-items: center; gap: 6px; line-height: 1; text-decoration: none; white-space: nowrap; }
.btn:hover { background: #f7f8fa; color: var(--text); }
.btn svg { width: 15px; height: 15px; }
.btn.icon { width: 34px; padding: 0; justify-content: center; }
/* more menu */
.more-wrap { position: relative; }
.menu { display: none; position: absolute; top: calc(100% + 6px); right: 0; z-index: 40; width: 168px; background: #fff; border: 1px solid var(--border); border-radius: 9px; box-shadow: 0 12px 32px rgba(20,24,33,.16); padding: 5px; }
.menu.open { display: block; }
.menu-item { display: flex; align-items: center; gap: 10px; width: 100%; border: none; background: transparent; font-family: inherit; font-size: 13px; font-weight: 500; color: var(--text); padding: 8px 9px; border-radius: var(--radius-sm); cursor: pointer; text-align: left; white-space: nowrap; }
.menu-item:hover { background: #f5f6f8; }
.menu-item svg { width: 16px; height: 16px; color: var(--text-3); flex-shrink: 0; }
.menu-item.danger { color: var(--red); }
.menu-item.danger svg { color: var(--red); }
.menu-item.danger:hover { background: var(--red-weak); }
.menu-sep { height: 1px; background: var(--border); margin: 5px 0; }
/* modal */
.modal-backdrop { position: fixed; inset: 0; background: rgba(20,24,33,.5); display: none; align-items: center; justify-content: center; z-index: 100; padding: 24px; }
.modal-backdrop.open { display: flex; }
.modal { width: 100%; max-width: 440px; background: #fff; border-radius: 14px; box-shadow: 0 24px 64px rgba(20,24,33,.32); overflow: hidden; }
.modal-body { padding: 26px 24px 8px; text-align: center; }
.modal-ico { width: 46px; height: 46px; border-radius: 50%; background: var(--red-weak); color: var(--red); display: grid; place-items: center; margin: 0 auto 14px; }
.modal-ico svg { width: 22px; height: 22px; }
.modal-title { font-size: 17px; font-weight: 700; letter-spacing: -.3px; }
.modal-desc { font-size: 13px; color: var(--text-2); line-height: 1.6; margin-top: 8px; }
.modal-desc b { color: var(--text); font-weight: 600; }
.modal-foot { display: flex; gap: 9px; padding: 18px 24px 20px; }
.mbtn { flex: 1; height: 40px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; border: 1px solid var(--border-strong); background: #fff; color: var(--text-2); cursor: pointer; }
.mbtn:hover { background: #f1f2f4; color: var(--text); }
.mbtn.danger { background: var(--red); border-color: var(--red); color: #fff; box-shadow: 0 1px 2px rgba(214,69,93,.4); }
.mbtn.danger:hover { background: #c23a51; }
.mbtn.approve { background: var(--green); border-color: var(--green); color: #fff; box-shadow: 0 1px 2px rgba(31,157,87,.4); }
.mbtn.approve:hover { background: #188a4b; }
.mbtn.request-send { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
.mbtn.request-send:hover { background: var(--accent-hover); }
.modal-textarea { width: 100%; min-height: 92px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 10px 12px; font-family: inherit; font-size: 13.5px; line-height: 1.6; color: var(--text); resize: vertical; }
.modal-textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
.modal-textarea::placeholder { color: var(--text-3); }
/* approval sidebar card */
.ap-card { padding: 16px 18px 18px; border-width: 1px; border-style: solid; }
.ap-card.review { border-color: var(--amber-border); background: var(--amber-weak); }
.ap-card.request { border-color: var(--accent-border); background: var(--accent-weak); }
.ap-card.done { border-color: #cde8d8; background: var(--green-weak); }
.ap-card.done .ap-card-ico { color: var(--green); border-color: #cde8d8; }
.ap-card.done .ap-card-title { color: #167a44; }
.ap-card-head { display: flex; align-items: center; gap: 9px; }
.ap-card-ico { width: 30px; height: 30px; border-radius: 8px; background: #fff; display: grid; place-items: center; flex-shrink: 0; border: 1px solid; }
.ap-card.review .ap-card-ico { color: var(--amber); border-color: var(--amber-border); }
.ap-card.request .ap-card-ico { color: var(--accent); border-color: var(--accent-border); }
.ap-card-ico svg { width: 17px; height: 17px; }
.ap-card-title { font-size: 14px; font-weight: 700; white-space: nowrap; }
.ap-card.review .ap-card-title { color: #8a5a12; }
.ap-card.request .ap-card-title { color: var(--accent-hover); }
.ap-card-meta { font-size: 12px; color: var(--text-2); margin-top: 10px; line-height: 1.5; }
.ap-card-meta b { color: var(--text); font-weight: 600; }
.ap-card-desc { font-size: 12.5px; color: var(--text-2); margin-top: 10px; line-height: 1.55; }
.ap-card-note { font-size: 12.5px; color: var(--text); background: #fff; border: 1px solid var(--amber-border); border-radius: 8px; padding: 9px 11px; margin-top: 10px; line-height: 1.5; }
.ap-card-actions { display: flex; flex-direction: column; gap: 8px; margin-top: 13px; }
.ap-btn { height: 38px; padding: 0 14px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; cursor: pointer; border: 1px solid var(--border-strong); background: #fff; color: var(--text-2); display: inline-flex; align-items: center; justify-content: center; gap: 6px; }
.ap-btn svg { width: 15px; height: 15px; }
.ap-btn.reject:hover { background: #fdeef0; border-color: var(--red-border); color: var(--red); }
.ap-btn.approve { background: var(--green); border-color: var(--green); color: #fff; box-shadow: 0 1px 2px rgba(31,157,87,.4); }
.ap-btn.approve:hover { background: #188a4b; }
.ap-btn.request-send { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
.ap-btn.request-send:hover { background: var(--accent-hover); }
/* approval system event in thread */
.sys-event { display: flex; align-items: center; gap: 10px; padding: 12px 0; border-top: 1px solid var(--border); font-size: 13px; color: var(--text-2); }
.sys-event .se-ico { width: 26px; height: 26px; border-radius: 50%; background: var(--amber-weak); color: var(--amber); display: grid; place-items: center; flex-shrink: 0; }
.sys-event .se-ico svg { width: 14px; height: 14px; }
.sys-event b { color: var(--text); font-weight: 600; }
.sys-event .se-time { margin-left: auto; font-size: 12px; color: var(--text-3); white-space: nowrap; }
/* layout */
.layout { display: grid; grid-template-columns: 1fr 320px; gap: 20px; align-items: start; }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow-sm); }
.main-card { padding: 22px 26px 26px; }
.sect + .sect { margin-top: 26px; padding-top: 22px; border-top: 1px solid var(--border); }
.sect-label { display: flex; align-items: center; gap: 8px; font-size: 12.5px; font-weight: 600; color: var(--text-2); margin-bottom: 12px; white-space: nowrap; }
.sect-label .pr { margin-left: auto; display: flex; align-items: center; gap: 9px; }
.progress-bar { width: 92px; height: 6px; border-radius: 4px; background: #eceef1; overflow: hidden; }
.progress-bar > i { display: block; height: 100%; background: var(--green); border-radius: 4px; }
.progress-txt { font-size: 12px; font-weight: 600; color: var(--text-2); white-space: nowrap; }
.content { font-size: 14px; color: var(--text); line-height: 1.7; }
.content p { margin-bottom: 11px; }
.content p:last-child { margin-bottom: 0; }
.content .mention { color: var(--accent); font-weight: 600; background: var(--accent-weak); padding: 0 3px; border-radius: 3px; }
/* checklist */
.checklist { display: flex; flex-direction: column; }
.citem { display: flex; align-items: center; gap: 11px; padding: 8px 0; border-radius: var(--radius-sm); }
.cbox { width: 18px; height: 18px; border-radius: 5px; border: 1.5px solid var(--border-strong); flex-shrink: 0; display: grid; place-items: center; background: #fff; }
.cbox.done { background: var(--green); border-color: var(--green); }
.cbox svg { width: 12px; height: 12px; color: #fff; opacity: 0; }
.cbox.done svg { opacity: 1; }
.citem .ctext { font-size: 13.5px; color: var(--text); white-space: nowrap; }
.citem.done .ctext { color: var(--text-3); text-decoration: line-through; }
/* nested subtasks (read-only) */
.task-group + .task-group { margin-top: 11px; padding-top: 11px; border-top: 1px solid var(--border); }
.citem.parent .ctext { font-weight: 600; }
.sub-count { margin-left: auto; font-size: 11px; font-weight: 600; color: var(--text-2); background: #f1f2f4; border: 1px solid var(--border); border-radius: 20px; padding: 1px 8px; line-height: 1.55; white-space: nowrap; }
.citem.parent.done .sub-count { color: var(--green); background: var(--green-weak); border-color: #cde8d8; }
.subtasks { margin-left: 29px; padding-left: 14px; border-left: 1.5px solid var(--border); display: flex; flex-direction: column; }
.subtask { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-radius: var(--radius-sm); }
.cbox.sm { width: 16px; height: 16px; border-radius: 5px; }
.cbox.sm svg { width: 11px; height: 11px; }
.subtask .ctext { font-size: 13px; color: var(--text-2); white-space: nowrap; }
.subtask.done .ctext { color: var(--text-3); text-decoration: line-through; }
/* comments */
.comment { display: flex; gap: 11px; padding: 14px 0; border-top: 1px solid var(--border); }
.comment:first-of-type { border-top: none; }
.c-av { width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; font-size: 12px; font-weight: 700; color: #fff; flex-shrink: 0; }
.c-body { flex: 1; min-width: 0; }
.c-head { display: flex; align-items: center; gap: 8px; }
.c-name { font-size: 13.5px; font-weight: 600; white-space: nowrap; }
.c-role { font-size: 11px; font-weight: 600; color: var(--accent); background: var(--accent-weak); border-radius: 20px; padding: 1px 7px; white-space: nowrap; }
.c-time { font-size: 12px; color: var(--text-3); white-space: nowrap; }
.c-text { font-size: 13.5px; color: var(--text); line-height: 1.6; margin-top: 4px; }
.c-file { display: inline-flex; align-items: center; gap: 7px; margin-top: 8px; padding: 6px 10px; border: 1px solid var(--border); border-radius: var(--radius); font-size: 12.5px; font-weight: 600; color: var(--text-2); }
.c-file .fi { width: 22px; height: 22px; border-radius: 5px; background: #e25950; color: #fff; display: grid; place-items: center; font-size: 8px; font-weight: 800; }
.composer { display: flex; gap: 11px; margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); }
.composer .field { flex: 1; }
.composer textarea { width: 100%; min-height: 64px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 10px 12px; font-family: inherit; font-size: 13.5px; resize: vertical; color: var(--text); }
.composer textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
.composer textarea::placeholder { color: var(--text-3); }
.composer .crow { display: flex; justify-content: flex-end; margin-top: 9px; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
.btn.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
/* sidebar */
.side { display: flex; flex-direction: column; gap: 16px; }
.side-card { padding: 16px 18px 18px; }
.side-field + .side-field { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); }
.side-label { font-size: 12px; font-weight: 600; color: var(--text-3); margin-bottom: 9px; white-space: nowrap; }
.status-badge { display: inline-flex; align-items: center; gap: 8px; height: 30px; padding: 0 13px; border: 1px solid var(--blue-border); background: var(--blue-weak); border-radius: 20px; font-size: 13px; font-weight: 600; color: var(--blue); white-space: nowrap; }
.status-badge .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--blue); }
.status-badge.review { color: var(--amber); background: var(--amber-weak); border-color: var(--amber-border); }
.status-badge.review .dot { background: var(--amber); }
.status-badge.done { color: var(--green); background: var(--green-weak); border-color: #cde8d8; }
.status-badge.done .dot { background: var(--green); }
.assignees { display: flex; flex-direction: column; gap: 9px; }
.ass { display: flex; align-items: center; gap: 9px; }
.avatar { width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center; font-size: 11px; font-weight: 700; color: #fff; flex-shrink: 0; }
.av-a { background: #e0518d; } .av-b { background: #3d8bf2; } .av-c { background: #16a37b; } .av-d { background: #b3631f; } .av-f { background: #d0982a; }
.ass .nm { font-size: 13px; font-weight: 600; white-space: nowrap; }
.ass .rl { font-size: 11.5px; color: var(--text-3); white-space: nowrap; }
.dl { display: flex; align-items: center; gap: 9px; font-size: 13.5px; font-weight: 600; color: var(--text); white-space: nowrap; }
.dl svg { width: 16px; height: 16px; color: var(--text-3); }
.dl .dday { font-size: 11.5px; font-weight: 700; color: var(--amber); background: #fdf4e3; border: 1px solid #f3e2bd; border-radius: 20px; padding: 1px 7px; margin-left: auto; white-space: nowrap; }
.files { display: flex; flex-direction: column; gap: 7px; }
.file { display: flex; align-items: center; gap: 10px; padding: 8px 9px; border: 1px solid var(--border); border-radius: var(--radius); }
.file-ico { width: 28px; height: 28px; border-radius: 6px; display: grid; place-items: center; flex-shrink: 0; font-size: 9px; font-weight: 800; color: #fff; }
.fi-img { background: #2aa775; } .fi-zip { background: #8a64d6; }
.file-info { min-width: 0; flex: 1; }
.file-name { font-size: 12.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; }
.file-size { font-size: 11.5px; color: var(--text-3); display: block; }
.issuer { display: flex; align-items: center; gap: 10px; }
.issuer .nm { font-size: 13px; font-weight: 600; white-space: nowrap; }
.issuer .rl { font-size: 11.5px; color: var(--text-3); white-space: nowrap; }
.meta-line { font-size: 12px; color: var(--text-3); margin-top: 4px; white-space: nowrap; }
</style>
</head>
<body>
<header class="topbar">
<div class="brand"><div class="logo">R</div>Relay</div>
<nav class="topnav">
<a href="내 업무.html">내 업무</a>
<a href="저장소 목록.html" class="active">저장소</a>
</nav>
<div class="topbar-right">
<button class="icon-btn" title="검색"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg></button>
<button class="icon-btn" title="알림"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg></button>
<div class="me" style="background:#3d8bf2"></div>
</div>
</header>
<div class="page" data-screen-label="업무 상세">
<div class="crumb">
<a href="저장소 목록.html" class="lnk">저장소</a>
<span class="sep">/</span>
<a href="저장소 상세.html" class="lnk">3분기 신제품 런칭 캠페인</a>
<span class="sep">/</span>
<a href="저장소 상세.html" class="lnk">업무</a>
<span class="sep">/</span>
<b>#9</b>
</div>
<div class="pagehead">
<div class="ph-main">
<div class="ph-titlerow">
<span class="st-badge done">완료</span>
<span class="ph-tid">#9</span>
</div>
<h1 class="ph-title">메인 배너 이미지 2종 (가로/세로) 디자인</h1>
</div>
<div class="actions">
<a class="btn" href="업무 작성.html"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>수정</a>
<div class="more-wrap">
<button class="btn icon" id="moreBtn" type="button" title="더보기"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg></button>
<div class="menu" id="moreMenu">
<button class="menu-item" type="button">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
업무 복제
</button>
<button class="menu-item" type="button">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/></svg>
링크 복사
</button>
<div class="menu-sep"></div>
<button class="menu-item danger" id="deleteOpen" type="button">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg>
업무 삭제
</button>
</div>
</div>
</div>
</div>
<div class="layout">
<!-- main -->
<section class="card main-card">
<div class="sect">
<div class="sect-label">업무 내용</div>
<div class="content">
<p>9월 신제품 런칭 캠페인에 사용할 메인 배너를 가로형/세로형 2종으로 제작합니다. 톤앤매너는 <span class="mention">@브랜드_가이드라인.pdf</span> 기준을 따라주세요.</p>
<p>퍼포먼스 광고와 자사몰 상단에 동시 노출되므로, 두 비율 모두 핵심 메시지("민감 피부 저자극")가 명확히 보이도록 구성해주세요. 1차 시안 확정 후 모바일 적응형까지 진행합니다.</p>
</div>
</div>
<div class="sect">
<div class="sect-label">해야 할 일
<span class="pr"><span class="progress-bar"><i style="width:100%"></i></span><span class="progress-txt">3 / 3 완료</span></span>
</div>
<div class="checklist">
<div class="citem done">
<span class="cbox done"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
<span class="ctext">가로형 메인 배너 1920×1080</span>
</div>
<div class="citem done">
<span class="cbox done"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
<span class="ctext">세로형 메인 배너 1080×1920</span>
</div>
<div class="citem done">
<span class="cbox done"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
<span class="ctext">모바일 적응형 600×600</span>
</div>
</div>
</div>
<div class="sect">
<div class="sect-label">댓글 <span style="color:var(--text-3);font-weight:500">3</span></div>
<div class="comment">
<span class="c-av av-f"></span>
<div class="c-body">
<div class="c-head"><span class="c-name">정태경</span><span class="c-role">지시자</span><span class="c-time">2일 전</span></div>
<div class="c-text">가로형부터 확정하고 세로형 진행해주세요. 카피 위치는 좌측 정렬 기준입니다.</div>
</div>
</div>
<div class="comment">
<span class="c-av av-b"></span>
<div class="c-body">
<div class="c-head"><span class="c-name">박지훈</span><span class="c-time">1일 전</span></div>
<div class="c-text">가로형 1차 시안 업로드했습니다. 검토 부탁드려요.</div>
<div class="c-file"><span class="fi">IMG</span>main_banner_h_v1.png</div>
</div>
</div>
<div class="comment">
<span class="c-av av-d"></span>
<div class="c-body">
<div class="c-head"><span class="c-name">최유진</span><span class="c-time">3시간 전</span></div>
<div class="c-text">세로형은 내일 오전까지 공유하겠습니다.</div>
</div>
</div>
<div class="sys-event">
<span class="se-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg></span>
<span><b>박지훈</b>님이 승인을 요청했습니다</span>
<span class="se-time">2시간 전</span>
</div>
<div class="composer">
<span class="c-av" style="background:#3d8bf2"></span>
<div class="field">
<textarea placeholder="댓글을 입력하세요…"></textarea>
<div class="crow"><button class="btn primary" type="button">댓글 등록</button></div>
</div>
</div>
</div>
</section>
<!-- sidebar -->
<aside class="side">
<!-- 승인 완료 -->
<div class="card ap-card done">
<div class="ap-card-head">
<span class="ap-card-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><path d="M22 4 12 14.01l-3-3"/></svg></span>
<span class="ap-card-title">승인 완료</span>
</div>
<div class="ap-card-meta"><b>정태경</b>님이 검토를 승인했습니다 · 방금 · 해야 할 일이 모두 완료되었습니다.</div>
</div>
<div class="card side-card">
<div class="side-field">
<div class="side-label">상태</div>
<span class="status-badge done"><span class="dot"></span>완료</span>
</div>
<div class="side-field">
<div class="side-label">담당자 · 2명</div>
<div class="assignees">
<div class="ass"><span class="avatar av-b"></span><span><div class="nm">박지훈</div><div class="rl">퍼포먼스 마케팅</div></span></div>
<div class="ass"><span class="avatar av-d"></span><span><div class="nm">최유진</div><div class="rl">UX 디자인</div></span></div>
</div>
</div>
<div class="side-field">
<div class="side-label">마감기한</div>
<div class="dl">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>
6월 18일 (목)
<span class="dday">D-3</span>
</div>
</div>
</div>
<div class="card side-card">
<div class="side-field">
<div class="side-label">첨부파일 · 2개</div>
<div class="files">
<div class="file"><span class="file-ico fi-img">IMG</span><span class="file-info"><span class="file-name">레퍼런스_무드보드.png</span><span class="file-size">3.2 MB</span></span></div>
<div class="file"><span class="file-ico fi-zip">ZIP</span><span class="file-info"><span class="file-name">배너_소스_파일.zip</span><span class="file-size">18.4 MB</span></span></div>
</div>
</div>
</div>
<div class="card side-card">
<div class="side-field">
<div class="side-label">지시자</div>
<div class="issuer"><span class="avatar av-f"></span><span><div class="nm">정태경</div><div class="rl">마케팅 그룹 리드</div></span></div>
<div class="meta-line">6월 8일 지시 · #9</div>
</div>
</div>
</aside>
</div>
</div>
<!-- 삭제 확인 모달 -->
<div class="modal-backdrop" id="deleteModal">
<div class="modal" role="dialog" aria-modal="true">
<div class="modal-body">
<div class="modal-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg></div>
<h2 class="modal-title">업무를 삭제할까요?</h2>
<p class="modal-desc"><b>#9 메인 배너 이미지 2종 (가로/세로) 디자인</b> 업무와 체크리스트·댓글·첨부파일이 영구적으로 삭제됩니다. 이 작업은 되돌릴 수 없습니다.</p>
</div>
<div class="modal-foot">
<button class="mbtn" id="deleteCancel" type="button">취소</button>
<button class="mbtn danger" type="button">삭제</button>
</div>
</div>
</div>
<!-- 승인 요청 모달 -->
<div class="modal-backdrop" id="requestModal">
<div class="modal" role="dialog" aria-modal="true">
<div class="modal-body" style="text-align:left;padding:22px 24px 8px">
<h2 class="modal-title">승인 요청 보내기</h2>
<p class="modal-desc" style="margin:8px 0 14px">지시자 <b>정태경</b>님에게 검토 승인을 요청합니다. 완료한 작업과 전달할 내용을 간단히 적어주세요. 업무가 <b>승인 대기</b> 상태로 변경됩니다.</p>
<textarea class="modal-textarea" placeholder="예) 가로형/세로형 메인 배너 1차 제작을 완료했습니다. 검토 부탁드립니다."></textarea>
</div>
<div class="modal-foot">
<button class="mbtn" data-close="requestModal" type="button">취소</button>
<button class="mbtn request-send" type="button">요청 보내기</button>
</div>
</div>
</div>
<script>
var moreBtn = document.getElementById('moreBtn');
var moreMenu = document.getElementById('moreMenu');
moreBtn.addEventListener('click', function (e) { e.stopPropagation(); moreMenu.classList.toggle('open'); });
document.addEventListener('click', function () { moreMenu.classList.remove('open'); });
moreMenu.addEventListener('click', function (e) { e.stopPropagation(); });
/* 삭제 모달 */
var dModal = document.getElementById('deleteModal');
document.getElementById('deleteOpen').addEventListener('click', function () { moreMenu.classList.remove('open'); dModal.classList.add('open'); });
document.getElementById('deleteCancel').addEventListener('click', function () { dModal.classList.remove('open'); });
dModal.addEventListener('click', function (e) { if (e.target === dModal) dModal.classList.remove('open'); });
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') dModal.classList.remove('open'); });
/* 승인 요청 모달 */
function openModal(id) { document.getElementById(id).classList.add('open'); }
function closeModal(el) { el.classList.remove('open'); }
document.querySelectorAll('[data-close]').forEach(function (b) {
b.addEventListener('click', function () { closeModal(document.getElementById(b.getAttribute('data-close'))); });
});
var reqModal = document.getElementById('requestModal');
reqModal.addEventListener('click', function (e) { if (e.target === reqModal) closeModal(reqModal); });
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeModal(reqModal); });
</script>
</body>
</html>
@@ -0,0 +1,462 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>메인 배너 이미지 2종 디자인 — Relay</title>
<link rel="preconnect" href="https://cdn.jsdelivr.net">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f4f5f7; --panel: #ffffff; --border: #e6e8ec; --border-strong: #d7dae0;
--text: #16181d; --text-2: #5b626e; --text-3: #9298a3;
--accent: #4f46e5; --accent-hover: #4338ca; --accent-weak: #eef0fe; --accent-border: #c7ccfb;
--green: #1f9d57; --green-weak: #e8f6ee; --blue: #1d4ed8; --blue-weak: #e8efff; --blue-border: #c9dbff;
--red: #d6455d; --red-weak: #fdeef0; --red-border: #f1c0c8; --amber: #b7791f; --amber-weak: #fdf4e3; --amber-border: #f3e2bd;
--radius: 6px; --radius-sm: 4px; --shadow-sm: 0 1px 2px rgba(20,24,33,.05);
}
html, body { background: var(--bg); color: var(--text); font-family: "Pretendard", -apple-system, system-ui, sans-serif; font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
body { min-height: 100vh; }
/* topbar */
.topbar { height: 52px; background: var(--panel); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 28px; padding: 0 20px; position: sticky; top: 0; z-index: 50; }
.brand { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 15px; letter-spacing: -.2px; }
.brand .logo { width: 26px; height: 26px; border-radius: 7px; background: linear-gradient(135deg, #5b54f0, #4338ca); display: grid; place-items: center; color: #fff; font-size: 15px; font-weight: 800; box-shadow: 0 2px 6px rgba(79,70,229,.35); }
.topnav { display: flex; align-items: center; gap: 2px; }
.topnav a { color: var(--text-2); text-decoration: none; font-size: 13.5px; font-weight: 500; padding: 7px 12px; border-radius: var(--radius-sm); line-height: 1; white-space: nowrap; }
.topnav a:hover { background: #f1f2f4; color: var(--text); }
.topnav a.active { color: var(--accent); background: var(--accent-weak); font-weight: 600; }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.icon-btn { width: 32px; height: 32px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-2); display: grid; place-items: center; cursor: pointer; }
.icon-btn:hover { background: #f1f2f4; color: var(--text); }
.me { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-size: 11.5px; font-weight: 700; color: #fff; background: #0ea5a3; margin-left: 4px; }
/* page */
.page { max-width: 1080px; margin: 0 auto; padding: 0 24px 60px; }
.crumb { display: flex; align-items: center; gap: 7px; color: var(--text-3); font-size: 12.5px; padding: 16px 0 0; white-space: nowrap; }
.crumb .lnk { color: var(--text-2); font-weight: 600; text-decoration: none; }
.crumb .lnk:hover { color: var(--accent); }
.crumb .sep { opacity: .5; }
.crumb b { color: var(--text-2); font-weight: 600; }
.pagehead { display: flex; align-items: flex-start; gap: 14px; padding: 12px 0 18px; }
.ph-main { flex: 1; min-width: 0; }
.ph-titlerow { display: flex; align-items: center; gap: 10px; }
.st-badge { font-size: 11.5px; font-weight: 600; padding: 3px 10px; border-radius: 20px; white-space: nowrap; }
.st-badge.prog { color: var(--blue); background: var(--blue-weak); border: 1px solid var(--blue-border); }
.st-badge.review { color: var(--amber); background: var(--amber-weak); border: 1px solid var(--amber-border); }
.st-badge.done { color: var(--green); background: var(--green-weak); border: 1px solid #cde8d8; }
.st-badge.changes { color: var(--red); background: var(--red-weak); border: 1px solid var(--red-border); }
.ph-tid { font-size: 14px; font-weight: 600; color: var(--text-3); }
h1.ph-title { font-size: 23px; font-weight: 700; letter-spacing: -.5px; margin-top: 8px; line-height: 1.3; }
.pagehead .actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; padding-top: 2px; }
.btn { height: 34px; padding: 0 14px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; border: 1px solid var(--border-strong); background: var(--panel); color: var(--text-2); cursor: pointer; display: inline-flex; align-items: center; gap: 6px; line-height: 1; text-decoration: none; white-space: nowrap; }
.btn:hover { background: #f7f8fa; color: var(--text); }
.btn svg { width: 15px; height: 15px; }
.btn.icon { width: 34px; padding: 0; justify-content: center; }
/* more menu */
.more-wrap { position: relative; }
.menu { display: none; position: absolute; top: calc(100% + 6px); right: 0; z-index: 40; width: 168px; background: #fff; border: 1px solid var(--border); border-radius: 9px; box-shadow: 0 12px 32px rgba(20,24,33,.16); padding: 5px; }
.menu.open { display: block; }
.menu-item { display: flex; align-items: center; gap: 10px; width: 100%; border: none; background: transparent; font-family: inherit; font-size: 13px; font-weight: 500; color: var(--text); padding: 8px 9px; border-radius: var(--radius-sm); cursor: pointer; text-align: left; white-space: nowrap; }
.menu-item:hover { background: #f5f6f8; }
.menu-item svg { width: 16px; height: 16px; color: var(--text-3); flex-shrink: 0; }
.menu-item.danger { color: var(--red); }
.menu-item.danger svg { color: var(--red); }
.menu-item.danger:hover { background: var(--red-weak); }
.menu-sep { height: 1px; background: var(--border); margin: 5px 0; }
/* modal */
.modal-backdrop { position: fixed; inset: 0; background: rgba(20,24,33,.5); display: none; align-items: center; justify-content: center; z-index: 100; padding: 24px; }
.modal-backdrop.open { display: flex; }
.modal { width: 100%; max-width: 440px; background: #fff; border-radius: 14px; box-shadow: 0 24px 64px rgba(20,24,33,.32); overflow: hidden; }
.modal-body { padding: 26px 24px 8px; text-align: center; }
.modal-ico { width: 46px; height: 46px; border-radius: 50%; background: var(--red-weak); color: var(--red); display: grid; place-items: center; margin: 0 auto 14px; }
.modal-ico svg { width: 22px; height: 22px; }
.modal-title { font-size: 17px; font-weight: 700; letter-spacing: -.3px; }
.modal-desc { font-size: 13px; color: var(--text-2); line-height: 1.6; margin-top: 8px; }
.modal-desc b { color: var(--text); font-weight: 600; }
.modal-foot { display: flex; gap: 9px; padding: 18px 24px 20px; }
.mbtn { flex: 1; height: 40px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; border: 1px solid var(--border-strong); background: #fff; color: var(--text-2); cursor: pointer; }
.mbtn:hover { background: #f1f2f4; color: var(--text); }
.mbtn.danger { background: var(--red); border-color: var(--red); color: #fff; box-shadow: 0 1px 2px rgba(214,69,93,.4); }
.mbtn.danger:hover { background: #c23a51; }
.mbtn.approve { background: var(--green); border-color: var(--green); color: #fff; box-shadow: 0 1px 2px rgba(31,157,87,.4); }
.mbtn.approve:hover { background: #188a4b; }
.mbtn.request-send { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
.mbtn.request-send:hover { background: var(--accent-hover); }
.modal-textarea { width: 100%; min-height: 92px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 10px 12px; font-family: inherit; font-size: 13.5px; line-height: 1.6; color: var(--text); resize: vertical; }
.modal-textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
.modal-textarea::placeholder { color: var(--text-3); }
/* approval sidebar card */
.ap-card { padding: 16px 18px 18px; border-width: 1px; border-style: solid; }
.ap-card.review { border-color: var(--amber-border); background: var(--amber-weak); }
.ap-card.request { border-color: var(--accent-border); background: var(--accent-weak); }
.ap-card-head { display: flex; align-items: center; gap: 9px; }
.ap-card-ico { width: 30px; height: 30px; border-radius: 8px; background: #fff; display: grid; place-items: center; flex-shrink: 0; border: 1px solid; }
.ap-card.review .ap-card-ico { color: var(--amber); border-color: var(--amber-border); }
.ap-card.request .ap-card-ico { color: var(--accent); border-color: var(--accent-border); }
.ap-card-ico svg { width: 17px; height: 17px; }
.ap-card-title { font-size: 14px; font-weight: 700; white-space: nowrap; }
.ap-card.review .ap-card-title { color: #8a5a12; }
.ap-card.request .ap-card-title { color: var(--accent-hover); }
.ap-card-meta { font-size: 12px; color: var(--text-2); margin-top: 10px; line-height: 1.5; }
.ap-card-meta b { color: var(--text); font-weight: 600; }
.ap-card-desc { font-size: 12.5px; color: var(--text-2); margin-top: 10px; line-height: 1.55; }
.ap-card-note { font-size: 12.5px; color: var(--text); background: #fff; border: 1px solid var(--amber-border); border-radius: 8px; padding: 9px 11px; margin-top: 10px; line-height: 1.5; }
.ap-card-actions { display: flex; flex-direction: column; gap: 8px; margin-top: 13px; }
.ap-btn { height: 38px; padding: 0 14px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; cursor: pointer; border: 1px solid var(--border-strong); background: #fff; color: var(--text-2); display: inline-flex; align-items: center; justify-content: center; gap: 6px; }
.ap-btn svg { width: 15px; height: 15px; }
.ap-btn.reject:hover { background: #fdeef0; border-color: var(--red-border); color: var(--red); }
.ap-btn.approve { background: var(--green); border-color: var(--green); color: #fff; box-shadow: 0 1px 2px rgba(31,157,87,.4); }
.ap-btn.approve:hover { background: #188a4b; }
.ap-btn.request-send { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
.ap-btn.request-send:hover { background: var(--accent-hover); }
/* approval system event in thread */
.sys-event { display: flex; align-items: center; gap: 10px; padding: 12px 0; border-top: 1px solid var(--border); font-size: 13px; color: var(--text-2); }
.sys-event .se-ico { width: 26px; height: 26px; border-radius: 50%; background: var(--amber-weak); color: var(--amber); display: grid; place-items: center; flex-shrink: 0; }
.sys-event .se-ico svg { width: 14px; height: 14px; }
.sys-event b { color: var(--text); font-weight: 600; }
.sys-event .se-time { margin-left: auto; font-size: 12px; color: var(--text-3); white-space: nowrap; }
/* layout */
.layout { display: grid; grid-template-columns: 1fr 320px; gap: 20px; align-items: start; }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow-sm); }
.main-card { padding: 22px 26px 26px; }
.sect + .sect { margin-top: 26px; padding-top: 22px; border-top: 1px solid var(--border); }
.sect-label { display: flex; align-items: center; gap: 8px; font-size: 12.5px; font-weight: 600; color: var(--text-2); margin-bottom: 12px; white-space: nowrap; }
.sect-label .pr { margin-left: auto; display: flex; align-items: center; gap: 9px; }
.progress-bar { width: 92px; height: 6px; border-radius: 4px; background: #eceef1; overflow: hidden; }
.progress-bar > i { display: block; height: 100%; background: var(--green); border-radius: 4px; }
.progress-txt { font-size: 12px; font-weight: 600; color: var(--text-2); white-space: nowrap; }
.content { font-size: 14px; color: var(--text); line-height: 1.7; }
.content p { margin-bottom: 11px; }
.content p:last-child { margin-bottom: 0; }
.content .mention { color: var(--accent); font-weight: 600; background: var(--accent-weak); padding: 0 3px; border-radius: 3px; }
/* checklist */
.checklist { display: flex; flex-direction: column; }
.citem { display: flex; align-items: center; gap: 11px; padding: 8px 0; border-radius: var(--radius-sm); }
.cbox { width: 18px; height: 18px; border-radius: 5px; border: 1.5px solid var(--border-strong); flex-shrink: 0; display: grid; place-items: center; background: #fff; }
.cbox.done { background: var(--green); border-color: var(--green); }
.cbox svg { width: 12px; height: 12px; color: #fff; opacity: 0; }
.cbox.done svg { opacity: 1; }
.citem .ctext { font-size: 13.5px; color: var(--text); white-space: nowrap; }
.citem.done .ctext { color: var(--text-3); text-decoration: line-through; }
/* nested subtasks (read-only) */
.task-group + .task-group { margin-top: 11px; padding-top: 11px; border-top: 1px solid var(--border); }
.citem.parent .ctext { font-weight: 600; }
.sub-count { margin-left: auto; font-size: 11px; font-weight: 600; color: var(--text-2); background: #f1f2f4; border: 1px solid var(--border); border-radius: 20px; padding: 1px 8px; line-height: 1.55; white-space: nowrap; }
.citem.parent.done .sub-count { color: var(--green); background: var(--green-weak); border-color: #cde8d8; }
.subtasks { margin-left: 29px; padding-left: 14px; border-left: 1.5px solid var(--border); display: flex; flex-direction: column; }
.subtask { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-radius: var(--radius-sm); }
.cbox.sm { width: 16px; height: 16px; border-radius: 5px; }
.cbox.sm svg { width: 11px; height: 11px; }
.subtask .ctext { font-size: 13px; color: var(--text-2); white-space: nowrap; }
.subtask.done .ctext { color: var(--text-3); text-decoration: line-through; }
/* comments */
.comment { display: flex; gap: 11px; padding: 14px 0; border-top: 1px solid var(--border); }
.comment:first-of-type { border-top: none; }
.c-av { width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; font-size: 12px; font-weight: 700; color: #fff; flex-shrink: 0; }
.c-body { flex: 1; min-width: 0; }
.c-head { display: flex; align-items: center; gap: 8px; }
.c-name { font-size: 13.5px; font-weight: 600; white-space: nowrap; }
.c-role { font-size: 11px; font-weight: 600; color: var(--accent); background: var(--accent-weak); border-radius: 20px; padding: 1px 7px; white-space: nowrap; }
.c-time { font-size: 12px; color: var(--text-3); white-space: nowrap; }
.c-text { font-size: 13.5px; color: var(--text); line-height: 1.6; margin-top: 4px; }
.c-file { display: inline-flex; align-items: center; gap: 7px; margin-top: 8px; padding: 6px 10px; border: 1px solid var(--border); border-radius: var(--radius); font-size: 12.5px; font-weight: 600; color: var(--text-2); }
.c-file .fi { width: 22px; height: 22px; border-radius: 5px; background: #e25950; color: #fff; display: grid; place-items: center; font-size: 8px; font-weight: 800; }
.composer { display: flex; gap: 11px; margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); }
.composer .field { flex: 1; }
.composer textarea { width: 100%; min-height: 64px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 10px 12px; font-family: inherit; font-size: 13.5px; resize: vertical; color: var(--text); }
.composer textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
.composer textarea::placeholder { color: var(--text-3); }
.composer .crow { display: flex; justify-content: flex-end; margin-top: 9px; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
.btn.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
/* sidebar */
.side { display: flex; flex-direction: column; gap: 16px; }
.side-card { padding: 16px 18px 18px; }
.side-field + .side-field { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); }
.side-label { font-size: 12px; font-weight: 600; color: var(--text-3); margin-bottom: 9px; white-space: nowrap; }
.status-badge { display: inline-flex; align-items: center; gap: 8px; height: 30px; padding: 0 13px; border: 1px solid var(--blue-border); background: var(--blue-weak); border-radius: 20px; font-size: 13px; font-weight: 600; color: var(--blue); white-space: nowrap; }
.status-badge .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--blue); }
.status-badge.review { color: var(--amber); background: var(--amber-weak); border-color: var(--amber-border); }
.status-badge.review .dot { background: var(--amber); }
.assignees { display: flex; flex-direction: column; gap: 9px; }
.ass { display: flex; align-items: center; gap: 9px; }
.avatar { width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center; font-size: 11px; font-weight: 700; color: #fff; flex-shrink: 0; }
.av-a { background: #e0518d; } .av-b { background: #3d8bf2; } .av-c { background: #16a37b; } .av-d { background: #b3631f; } .av-f { background: #d0982a; }
.ass .nm { font-size: 13px; font-weight: 600; white-space: nowrap; }
.ass .rl { font-size: 11.5px; color: var(--text-3); white-space: nowrap; }
.dl { display: flex; align-items: center; gap: 9px; font-size: 13.5px; font-weight: 600; color: var(--text); white-space: nowrap; }
.dl svg { width: 16px; height: 16px; color: var(--text-3); }
.dl .dday { font-size: 11.5px; font-weight: 700; color: var(--amber); background: #fdf4e3; border: 1px solid #f3e2bd; border-radius: 20px; padding: 1px 7px; margin-left: auto; white-space: nowrap; }
.files { display: flex; flex-direction: column; gap: 7px; }
.file { display: flex; align-items: center; gap: 10px; padding: 8px 9px; border: 1px solid var(--border); border-radius: var(--radius); }
.file-ico { width: 28px; height: 28px; border-radius: 6px; display: grid; place-items: center; flex-shrink: 0; font-size: 9px; font-weight: 800; color: #fff; }
.fi-img { background: #2aa775; } .fi-zip { background: #8a64d6; }
.file-info { min-width: 0; flex: 1; }
.file-name { font-size: 12.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; }
.file-size { font-size: 11.5px; color: var(--text-3); display: block; }
.issuer { display: flex; align-items: center; gap: 10px; }
.issuer .nm { font-size: 13px; font-weight: 600; white-space: nowrap; }
.issuer .rl { font-size: 11.5px; color: var(--text-3); white-space: nowrap; }
.meta-line { font-size: 12px; color: var(--text-3); margin-top: 4px; white-space: nowrap; }
</style>
</head>
<body>
<header class="topbar">
<div class="brand"><div class="logo">R</div>Relay</div>
<nav class="topnav">
<a href="내 업무.html">내 업무</a>
<a href="저장소 목록.html" class="active">저장소</a>
</nav>
<div class="topbar-right">
<button class="icon-btn" title="검색"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg></button>
<button class="icon-btn" title="알림"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg></button>
<div class="me" style="background:#3d8bf2"></div>
</div>
</header>
<div class="page" data-screen-label="업무 상세">
<div class="crumb">
<a href="저장소 목록.html" class="lnk">저장소</a>
<span class="sep">/</span>
<a href="저장소 상세.html" class="lnk">3분기 신제품 런칭 캠페인</a>
<span class="sep">/</span>
<a href="저장소 상세.html" class="lnk">업무</a>
<span class="sep">/</span>
<b>#9</b>
</div>
<div class="pagehead">
<div class="ph-main">
<div class="ph-titlerow">
<span class="st-badge prog">진행 중</span>
<span class="ph-tid">#9</span>
</div>
<h1 class="ph-title">메인 배너 이미지 2종 (가로/세로) 디자인</h1>
</div>
<div class="actions">
<a class="btn" href="업무 작성.html"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>수정</a>
<div class="more-wrap">
<button class="btn icon" id="moreBtn" type="button" title="더보기"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg></button>
<div class="menu" id="moreMenu">
<button class="menu-item" type="button">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
업무 복제
</button>
<button class="menu-item" type="button">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/></svg>
링크 복사
</button>
<div class="menu-sep"></div>
<button class="menu-item danger" id="deleteOpen" type="button">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg>
업무 삭제
</button>
</div>
</div>
</div>
</div>
<div class="layout">
<!-- main -->
<section class="card main-card">
<div class="sect">
<div class="sect-label">업무 내용</div>
<div class="content">
<p>9월 신제품 런칭 캠페인에 사용할 메인 배너를 가로형/세로형 2종으로 제작합니다. 톤앤매너는 <span class="mention">@브랜드_가이드라인.pdf</span> 기준을 따라주세요.</p>
<p>퍼포먼스 광고와 자사몰 상단에 동시 노출되므로, 두 비율 모두 핵심 메시지("민감 피부 저자극")가 명확히 보이도록 구성해주세요. 1차 시안 확정 후 모바일 적응형까지 진행합니다.</p>
</div>
</div>
<div class="sect">
<div class="sect-label">해야 할 일
<span class="pr"><span class="progress-bar"><i style="width:33%"></i></span><span class="progress-txt">1 / 3 완료</span></span>
</div>
<div class="checklist">
<div class="citem done">
<span class="cbox done"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
<span class="ctext">가로형 메인 배너 1920×1080</span>
</div>
<div class="citem">
<span class="cbox"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
<span class="ctext">세로형 메인 배너 1080×1920</span>
</div>
<div class="citem">
<span class="cbox"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
<span class="ctext">모바일 적응형 600×600</span>
</div>
</div>
</div>
<div class="sect">
<div class="sect-label">댓글 <span style="color:var(--text-3);font-weight:500">3</span></div>
<div class="comment">
<span class="c-av av-f"></span>
<div class="c-body">
<div class="c-head"><span class="c-name">정태경</span><span class="c-role">지시자</span><span class="c-time">2일 전</span></div>
<div class="c-text">가로형부터 확정하고 세로형 진행해주세요. 카피 위치는 좌측 정렬 기준입니다.</div>
</div>
</div>
<div class="comment">
<span class="c-av av-b"></span>
<div class="c-body">
<div class="c-head"><span class="c-name">박지훈</span><span class="c-time">1일 전</span></div>
<div class="c-text">가로형 1차 시안 업로드했습니다. 검토 부탁드려요.</div>
<div class="c-file"><span class="fi">IMG</span>main_banner_h_v1.png</div>
</div>
</div>
<div class="comment">
<span class="c-av av-d"></span>
<div class="c-body">
<div class="c-head"><span class="c-name">최유진</span><span class="c-time">3시간 전</span></div>
<div class="c-text">세로형은 내일 오전까지 공유하겠습니다.</div>
</div>
</div>
<div class="sys-event">
<span class="se-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg></span>
<span><b>박지훈</b>님이 승인을 요청했습니다</span>
<span class="se-time">2시간 전</span>
</div>
<div class="composer">
<span class="c-av" style="background:#3d8bf2"></span>
<div class="field">
<textarea placeholder="댓글을 입력하세요…"></textarea>
<div class="crow"><button class="btn primary" type="button">댓글 등록</button></div>
</div>
</div>
</div>
</section>
<!-- sidebar -->
<aside class="side">
<!-- 승인 대기: 지시자(정태경) 시점 결정 카드 -->
<div class="card ap-card request">
<div class="ap-card-head">
<span class="ap-card-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/></svg></span>
<span class="ap-card-title">지시자에게 승인 요청</span>
</div>
<div class="ap-card-desc">작업을 마쳤다면 지시자 <b style="color:var(--text);font-weight:600">정태경</b>님에게 검토 승인을 요청하세요. 승인되면 업무가 완료 처리됩니다.</div>
<div class="ap-card-actions">
<button class="ap-btn request-send" id="requestOpen" type="button"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/></svg>승인 요청 보내기</button>
</div>
</div>
<div class="card side-card">
<div class="side-field">
<div class="side-label">상태</div>
<span class="status-badge"><span class="dot"></span>진행 중</span>
</div>
<div class="side-field">
<div class="side-label">담당자 · 2명</div>
<div class="assignees">
<div class="ass"><span class="avatar av-b"></span><span><div class="nm">박지훈</div><div class="rl">퍼포먼스 마케팅</div></span></div>
<div class="ass"><span class="avatar av-d"></span><span><div class="nm">최유진</div><div class="rl">UX 디자인</div></span></div>
</div>
</div>
<div class="side-field">
<div class="side-label">마감기한</div>
<div class="dl">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>
6월 18일 (목)
<span class="dday">D-3</span>
</div>
</div>
</div>
<div class="card side-card">
<div class="side-field">
<div class="side-label">첨부파일 · 2개</div>
<div class="files">
<div class="file"><span class="file-ico fi-img">IMG</span><span class="file-info"><span class="file-name">레퍼런스_무드보드.png</span><span class="file-size">3.2 MB</span></span></div>
<div class="file"><span class="file-ico fi-zip">ZIP</span><span class="file-info"><span class="file-name">배너_소스_파일.zip</span><span class="file-size">18.4 MB</span></span></div>
</div>
</div>
</div>
<div class="card side-card">
<div class="side-field">
<div class="side-label">지시자</div>
<div class="issuer"><span class="avatar av-f"></span><span><div class="nm">정태경</div><div class="rl">마케팅 그룹 리드</div></span></div>
<div class="meta-line">6월 8일 지시 · #9</div>
</div>
</div>
</aside>
</div>
</div>
<!-- 삭제 확인 모달 -->
<div class="modal-backdrop" id="deleteModal">
<div class="modal" role="dialog" aria-modal="true">
<div class="modal-body">
<div class="modal-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg></div>
<h2 class="modal-title">업무를 삭제할까요?</h2>
<p class="modal-desc"><b>#9 메인 배너 이미지 2종 (가로/세로) 디자인</b> 업무와 체크리스트·댓글·첨부파일이 영구적으로 삭제됩니다. 이 작업은 되돌릴 수 없습니다.</p>
</div>
<div class="modal-foot">
<button class="mbtn" id="deleteCancel" type="button">취소</button>
<button class="mbtn danger" type="button">삭제</button>
</div>
</div>
</div>
<!-- 승인 요청 모달 -->
<div class="modal-backdrop" id="requestModal">
<div class="modal" role="dialog" aria-modal="true">
<div class="modal-body" style="text-align:left;padding:22px 24px 8px">
<h2 class="modal-title">승인 요청 보내기</h2>
<p class="modal-desc" style="margin:8px 0 14px">지시자 <b>정태경</b>님에게 검토 승인을 요청합니다. 완료한 작업과 전달할 내용을 간단히 적어주세요. 업무가 <b>승인 대기</b> 상태로 변경됩니다.</p>
<textarea class="modal-textarea" placeholder="예) 가로형/세로형 메인 배너 1차 제작을 완료했습니다. 검토 부탁드립니다."></textarea>
</div>
<div class="modal-foot">
<button class="mbtn" data-close="requestModal" type="button">취소</button>
<button class="mbtn request-send" type="button">요청 보내기</button>
</div>
</div>
</div>
<script>
var moreBtn = document.getElementById('moreBtn');
var moreMenu = document.getElementById('moreMenu');
moreBtn.addEventListener('click', function (e) { e.stopPropagation(); moreMenu.classList.toggle('open'); });
document.addEventListener('click', function () { moreMenu.classList.remove('open'); });
moreMenu.addEventListener('click', function (e) { e.stopPropagation(); });
/* 삭제 모달 */
var dModal = document.getElementById('deleteModal');
document.getElementById('deleteOpen').addEventListener('click', function () { moreMenu.classList.remove('open'); dModal.classList.add('open'); });
document.getElementById('deleteCancel').addEventListener('click', function () { dModal.classList.remove('open'); });
dModal.addEventListener('click', function (e) { if (e.target === dModal) dModal.classList.remove('open'); });
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') dModal.classList.remove('open'); });
/* 승인 요청 모달 */
function openModal(id) { document.getElementById(id).classList.add('open'); }
function closeModal(el) { el.classList.remove('open'); }
document.getElementById('requestOpen').addEventListener('click', function () { openModal('requestModal'); });
document.querySelectorAll('[data-close]').forEach(function (b) {
b.addEventListener('click', function () { closeModal(document.getElementById(b.getAttribute('data-close'))); });
});
var reqModal = document.getElementById('requestModal');
reqModal.addEventListener('click', function (e) { if (e.target === reqModal) closeModal(reqModal); });
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeModal(reqModal); });
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+482
View File
@@ -0,0 +1,482 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>새 업무 작성 — Relay</title>
<link rel="preconnect" href="https://cdn.jsdelivr.net">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f4f5f7;
--panel: #ffffff;
--border: #e6e8ec;
--border-strong: #d7dae0;
--text: #16181d;
--text-2: #5b626e;
--text-3: #9298a3;
--accent: #4f46e5;
--accent-hover: #4338ca;
--accent-weak: #eef0fe;
--accent-border: #c7ccfb;
--green: #1f9d57;
--green-weak: #e8f6ee;
--amber: #b7791f;
--radius: 6px;
--radius-sm: 4px;
--shadow-sm: 0 1px 2px rgba(20,24,33,.05);
--shadow-md: 0 4px 16px rgba(20,24,33,.10), 0 1px 3px rgba(20,24,33,.06);
--shadow-pop: 0 8px 28px rgba(20,24,33,.16), 0 2px 6px rgba(20,24,33,.08);
}
html, body {
background: var(--bg);
color: var(--text);
font-family: "Pretendard", -apple-system, system-ui, sans-serif;
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
body { min-height: 100vh; }
/* ---------- Top app bar ---------- */
.topbar {
height: 52px;
background: var(--panel);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 28px;
padding: 0 20px;
position: sticky;
top: 0;
z-index: 50;
}
.brand { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 15px; letter-spacing: -.2px; }
.brand .logo {
width: 26px; height: 26px; border-radius: 7px;
background: linear-gradient(135deg, #5b54f0, #4338ca);
display: grid; place-items: center; color: #fff;
font-size: 15px; font-weight: 800;
box-shadow: 0 2px 6px rgba(79,70,229,.35);
}
.topnav { display: flex; align-items: center; gap: 2px; }
.topnav a {
color: var(--text-2); text-decoration: none; font-size: 13.5px; font-weight: 500;
padding: 7px 12px; border-radius: var(--radius-sm); line-height: 1; white-space: nowrap;
}
.topnav a:hover { background: #f1f2f4; color: var(--text); }
.topnav a.active { color: var(--accent); background: var(--accent-weak); font-weight: 600; }
.crumb .lnk { color: var(--text-2); font-weight: 600; text-decoration: none; }
.crumb .lnk:hover { color: var(--accent); }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.icon-btn {
width: 32px; height: 32px; border-radius: var(--radius-sm); border: none; background: transparent;
color: var(--text-2); display: grid; place-items: center; cursor: pointer;
}
.icon-btn:hover { background: #f1f2f4; color: var(--text); }
.me {
width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center;
font-size: 11.5px; font-weight: 700; color: #fff; background: #0ea5a3; margin-left: 4px;
}
/* ---------- Page header ---------- */
.page { max-width: 1240px; margin: 0 auto; padding: 0 24px 60px; }
.crumb { display: flex; align-items: center; gap: 7px; color: var(--text-3); font-size: 12.5px; padding: 16px 0 0; white-space: nowrap; }
.crumb b { color: var(--text-2); font-weight: 600; }
.crumb .sep { opacity: .5; }
.pagehead { display: flex; align-items: center; gap: 16px; padding: 8px 0 18px; }
.pagehead h1 { font-size: 21px; font-weight: 700; letter-spacing: -.4px; white-space: nowrap; flex-shrink: 0; }
.draft-tag {
font-size: 11.5px; font-weight: 600; color: var(--amber); background: #fdf4e3;
border: 1px solid #f3e2bd; padding: 3px 8px; border-radius: 20px; line-height: 1; white-space: nowrap;
}
.pagehead .actions { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.btn {
height: 34px; padding: 0 14px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600;
border: 1px solid var(--border-strong); background: var(--panel); color: var(--text-2);
cursor: pointer; display: inline-flex; align-items: center; gap: 6px; line-height: 1; text-decoration: none;
}
.btn:hover { background: #f7f8fa; color: var(--text); }
.btn.ghost { border-color: transparent; background: transparent; }
.btn.ghost:hover { background: #eceef1; }
.btn.primary {
background: var(--accent); border-color: var(--accent); color: #fff;
box-shadow: 0 1px 2px rgba(79,70,229,.4);
}
.btn.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
/* ---------- Layout ---------- */
.layout { display: grid; grid-template-columns: 1fr 348px; gap: 20px; align-items: start; }
.card {
background: var(--panel); border: 1px solid var(--border);
border-radius: 10px; box-shadow: var(--shadow-sm);
}
.main-card { padding: 8px 26px 26px; }
/* Title field */
.title-input {
width: 100%; border: none; outline: none; background: transparent;
font-family: inherit; font-size: 24px; font-weight: 700; letter-spacing: -.5px;
color: var(--text); padding: 20px 0 14px; line-height: 1.3;
}
.title-input::placeholder { color: #c4c8cf; }
.divider { height: 1px; background: var(--border); margin: 0 -26px; }
.field { padding-top: 22px; }
.field-label {
display: flex; align-items: center; gap: 7px;
font-size: 12.5px; font-weight: 600; color: var(--text-2); margin-bottom: 9px; white-space: nowrap;
}
.field-label .req { color: var(--accent); font-weight: 700; }
.field-label .hint { margin-left: auto; font-weight: 500; color: var(--text-3); font-size: 12px; }
/* Content editor */
.editor { border: 1px solid var(--border-strong); border-radius: var(--radius); overflow: hidden; }
.editor:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
.toolbar {
display: flex; align-items: center; gap: 2px; padding: 6px 8px;
border-bottom: 1px solid var(--border); background: #fafbfc;
}
.tb {
height: 28px; min-width: 28px; padding: 0 7px; border-radius: var(--radius-sm); border: none; background: transparent;
color: var(--text-2); font-size: 13px; font-weight: 600; cursor: pointer; display: grid; place-items: center;
}
.tb:hover { background: #eceef1; color: var(--text); }
.tb svg { width: 16px; height: 16px; }
.tb-sep { width: 1px; height: 18px; background: var(--border-strong); margin: 0 5px; }
.editor-body { padding: 14px 16px; font-size: 14px; color: var(--text); min-height: 150px; line-height: 1.65; }
.editor-body p { margin-bottom: 11px; }
.editor-body p:last-child { margin-bottom: 0; }
.editor-body .mention { color: var(--accent); font-weight: 600; background: var(--accent-weak); padding: 0 3px; border-radius: 3px; }
/* Checklist */
.check-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.progress-wrap { margin-left: auto; display: flex; align-items: center; gap: 9px; }
.progress-bar { width: 92px; height: 6px; border-radius: 4px; background: #eceef1; overflow: hidden; }
.progress-bar > i { display: block; height: 100%; background: var(--green); border-radius: 4px; }
.progress-txt { font-size: 12px; font-weight: 600; color: var(--text-2); white-space: nowrap; }
.checklist { display: flex; flex-direction: column; }
.check-item {
display: flex; align-items: center; gap: 11px; padding: 9px 0;
border-radius: var(--radius-sm); position: relative;
}
.check-item:hover { background: #f7f8fa; }
.check-item .grip { position: absolute; left: -21px; top: 50%; transform: translateY(-50%); color: #c4c8cf; cursor: grab; opacity: 0; font-size: 14px; line-height: 1; letter-spacing: -2px; }
.check-item:hover .grip { opacity: 1; }
.cbox {
width: 18px; height: 18px; border-radius: 5px; border: 1.5px solid var(--border-strong);
flex-shrink: 0; display: grid; place-items: center; background: #fff; cursor: pointer;
}
.cbox.done { background: var(--green); border-color: var(--green); }
.cbox svg { width: 12px; height: 12px; color: #fff; opacity: 0; }
.cbox.done svg { opacity: 1; }
.check-item .txt { font-size: 13.5px; color: var(--text); flex: 1; }
.check-item.done .txt { color: var(--text-3); text-decoration: line-through; }
.check-item .row-del { opacity: 0; color: var(--text-3); cursor: pointer; width: 24px; height: 24px; display: grid; place-items: center; border-radius: 4px; }
.check-item:hover .row-del { opacity: 1; }
.check-item .row-del:hover { background: #eceef1; color: #d6455d; }
.add-row {
display: flex; align-items: center; gap: 11px; padding: 10px 0 4px 0; color: var(--text-3);
font-size: 13.5px; cursor: text;
}
.add-row .plus { width: 18px; height: 18px; border-radius: 5px; border: 1.5px dashed var(--border-strong); display: grid; place-items: center; font-size: 13px; line-height: 1; }
/* nested subtasks */
.task-group + .task-group { border-top: 1px solid var(--border); }
.check-item.parent .txt { font-weight: 600; }
.sub-count {
font-size: 11px; font-weight: 600; color: var(--text-2); background: #f1f2f4;
border: 1px solid var(--border); border-radius: 20px; padding: 1px 8px; line-height: 1.55; white-space: nowrap;
}
.check-item.parent.done .sub-count { color: var(--green); background: var(--green-weak); border-color: #cde8d8; }
.subtasks { margin-left: 31px; padding-left: 14px; border-left: 1.5px solid var(--border); display: flex; flex-direction: column; }
.subtask {
display: flex; align-items: center; gap: 10px; padding: 6px 0;
border-radius: var(--radius-sm); position: relative;
}
.subtask:hover { background: #f7f8fa; }
.subtask .grip { position: absolute; left: -19px; top: 50%; transform: translateY(-50%); color: #c4c8cf; cursor: grab; opacity: 0; font-size: 13px; line-height: 1; letter-spacing: -2px; }
.subtask:hover .grip { opacity: 1; }
.cbox.sm { width: 16px; height: 16px; border-radius: 5px; }
.cbox.sm svg { width: 11px; height: 11px; }
.subtask .txt { font-size: 13px; color: var(--text-2); flex: 1; }
.subtask.done .txt { color: var(--text-3); text-decoration: line-through; }
.subtask .row-del { opacity: 0; color: var(--text-3); cursor: pointer; width: 22px; height: 22px; display: grid; place-items: center; border-radius: 4px; }
.subtask:hover .row-del { opacity: 1; }
.subtask .row-del:hover { background: #eceef1; color: #d6455d; }
.add-sub {
display: flex; align-items: center; gap: 9px; margin-left: 31px; padding: 7px 0 9px 0;
color: var(--text-3); font-size: 12.5px; cursor: text;
}
.add-sub .plus-sm { width: 16px; height: 16px; border-radius: 5px; border: 1.5px dashed var(--border-strong); display: grid; place-items: center; font-size: 12px; line-height: 1; }
/* ---------- Sidebar ---------- */
.side { display: flex; flex-direction: column; gap: 16px; }
.side-card { padding: 16px 18px 18px; }
.side-field + .side-field { margin-top: 18px; padding-top: 18px; border-top: 1px solid var(--border); }
.side-label { font-size: 12px; font-weight: 600; color: var(--text-2); margin-bottom: 9px; display: flex; align-items: center; gap: 6px; white-space: nowrap; }
.side-label .req { color: var(--accent); }
/* assignee combobox */
.combo { position: relative; }
.combo-field {
border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 7px 8px; background: #fff;
display: flex; flex-wrap: wrap; gap: 6px; align-items: center;
}
.combo-field:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
.combo-caret { color: var(--text-3); display: grid; place-items: center; margin-left: auto; flex-shrink: 0; }
.combo-caret svg { width: 16px; height: 16px; }
.chip {
display: inline-flex; align-items: center; gap: 6px; background: #f1f2f4; border: 1px solid var(--border);
border-radius: 20px; padding: 3px 8px 3px 3px; font-size: 12.5px; font-weight: 600; color: var(--text); white-space: nowrap;
}
.chip .x { color: var(--text-3); cursor: pointer; font-size: 13px; line-height: 1; margin-left: 1px; }
.chip .x:hover { color: #d6455d; }
.combo-input { flex: 1; min-width: 70px; border: none; outline: none; font-family: inherit; font-size: 13px; padding: 4px 2px; background: transparent; }
.combo-input::placeholder { color: var(--text-3); }
.avatar { width: 22px; height: 22px; border-radius: 50%; display: grid; place-items: center; font-size: 10.5px; font-weight: 700; color: #fff; flex-shrink: 0; }
.av-a { background: #e0518d; } .av-b { background: #3d8bf2; } .av-c { background: #16a37b; }
.av-d { background: #b3631f; } .av-e { background: #7c5cf0; } .av-f { background: #d0982a; }
.dropdown {
position: absolute; top: calc(100% + 6px); left: 0; right: 0; z-index: 40;
background: #fff; border: 1px solid var(--border); border-radius: 8px; box-shadow: var(--shadow-pop);
padding: 6px; overflow: hidden;
}
.dd-search { font-size: 11px; color: var(--text-3); padding: 4px 8px 6px; font-weight: 600; }
.dd-item {
display: flex; align-items: center; gap: 10px; padding: 7px 8px; border-radius: var(--radius-sm); cursor: pointer;
}
.dd-item:hover { background: #f5f6f8; }
.dd-item.sel { background: var(--accent-weak); }
.dd-meta { display: flex; flex-direction: column; min-width: 0; flex: 1; }
.dd-name { font-size: 13px; font-weight: 600; color: var(--text); line-height: 1.3; }
.dd-role { font-size: 11.5px; color: var(--text-3); line-height: 1.3; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.dd-check { flex-shrink: 0; width: 17px; height: 17px; border-radius: 5px; border: 1.5px solid var(--border-strong); display: grid; place-items: center; background: #fff; }
.dd-item.sel .dd-check { background: var(--accent); border-color: var(--accent); }
.dd-check svg { width: 11px; height: 11px; color: #fff; opacity: 0; }
.dd-item.sel .dd-check svg { opacity: 1; }
/* date */
.input {
width: 100%; height: 38px; border: 1px solid var(--border-strong); border-radius: var(--radius);
padding: 0 11px; font-family: inherit; font-size: 13.5px; color: var(--text); background: #fff;
display: flex; align-items: center; gap: 9px;
}
.input .ico { color: var(--text-3); display: grid; place-items: center; }
.input .ico svg { width: 16px; height: 16px; }
.date-meta { font-size: 12px; color: var(--amber); font-weight: 600; margin-top: 7px; display: flex; align-items: center; gap: 5px; }
.date-meta .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--amber); }
/* attachments */
.dropzone {
border: 1.5px dashed var(--border-strong); border-radius: var(--radius); padding: 13px;
text-align: center; color: var(--text-3); font-size: 12.5px; cursor: pointer; background: #fafbfc;
}
.dropzone:hover { border-color: var(--accent-border); background: var(--accent-weak); color: var(--accent); }
.dropzone b { color: var(--accent); font-weight: 600; }
.files { display: flex; flex-direction: column; gap: 7px; margin-bottom: 10px; }
.file {
display: flex; align-items: center; gap: 10px; padding: 8px 9px; border: 1px solid var(--border);
border-radius: var(--radius); background: #fff;
}
.file-ico { width: 30px; height: 30px; border-radius: 6px; display: grid; place-items: center; flex-shrink: 0; font-size: 10px; font-weight: 800; color: #fff; letter-spacing: -.3px; }
.fi-pdf { background: #e25950; } .fi-zip { background: #8a64d6; } .fi-doc { background: #3d8bf2; }
.file-info { min-width: 0; flex: 1; }
.file-name { font-size: 12.5px; font-weight: 600; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; }
.file-size { font-size: 11.5px; color: var(--text-3); display: block; }
.file .x { color: var(--text-3); cursor: pointer; width: 22px; height: 22px; display: grid; place-items: center; border-radius: 4px; flex-shrink: 0; }
.file .x:hover { background: #eceef1; color: #d6455d; }
/* issuer meta */
.issuer { display: flex; align-items: center; gap: 10px; }
.issuer .avatar { width: 30px; height: 30px; font-size: 12px; }
.issuer .nm { font-size: 13px; font-weight: 600; }
.issuer .rl { font-size: 11.5px; color: var(--text-3); white-space: nowrap; }
.side-foot { font-size: 11.5px; color: var(--text-3); line-height: 1.6; }
</style>
</head>
<body>
<header class="topbar">
<div class="brand"><div class="logo">R</div>Relay</div>
<nav class="topnav">
<a href="내 업무.html">내 업무</a>
<a href="저장소 목록.html" class="active">저장소</a>
</nav>
<div class="topbar-right">
<button class="icon-btn" title="검색">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>
</button>
<button class="icon-btn" title="알림">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
</button>
<div class="me"></div>
</div>
</header>
<div class="page">
<div class="crumb"><a href="저장소 목록.html" class="lnk">저장소</a> <span class="sep">/</span> <a href="저장소 상세.html" class="lnk">3분기 신제품 런칭 캠페인</a> <span class="sep">/</span> 새 업무</div>
<div class="pagehead">
<h1>새 업무 작성</h1>
<span class="draft-tag">임시저장됨 · 방금</span>
<div class="actions">
<a class="btn ghost" href="저장소 상세.html">취소</a>
<button class="btn">임시저장</button>
<button class="btn primary">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/></svg>
지시 보내기
</button>
</div>
</div>
<div class="layout">
<!-- ============ MAIN ============ -->
<section class="card main-card">
<input class="title-input" value="3분기 신제품 런칭 캠페인 소재 제작" placeholder="업무 제목을 입력하세요">
<div class="divider"></div>
<!-- 내용 -->
<div class="field">
<div class="field-label"><span class="req">*</span> 업무 내용</div>
<div class="editor">
<div class="toolbar">
<button class="tb" style="font-weight:800">B</button>
<button class="tb" style="font-style:italic;font-family:Georgia,serif">I</button>
<button class="tb" style="text-decoration:underline">U</button>
<span class="tb-sep"></span>
<button class="tb" title="목록"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg></button>
<button class="tb" title="번호 목록"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 6h11M10 12h11M10 18h11M4 6h1v4M4 10h2M6 18H4l2-3H4"/></svg></button>
<span class="tb-sep"></span>
<button class="tb" title="링크"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/></svg></button>
<button class="tb" title="멘션">@</button>
<button class="tb" title="코드"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m16 18 6-6-6-6M8 6l-6 6 6 6"/></svg></button>
</div>
<div class="editor-body">
<p>9월 신제품(라인 클렌저) 정식 출시에 맞춰 사용할 캠페인 소재 일체를 제작합니다. 톤앤매너는 <span class="mention">@브랜드_가이드라인.pdf</span> 기준을 따라주세요.</p>
<p>퍼포먼스 광고용 배너와 자사몰 상세페이지를 우선순위로 진행하고, SNS 콘텐츠는 1차 시안 확정 후 착수합니다. 카피는 "민감 피부 저자극" 메시지를 핵심으로 잡되, 과장 광고 표현은 법무 검토를 반드시 거쳐주세요.</p>
<p>각 산출물은 아래 체크리스트 기준으로 관리하며, 진행 상황은 업무 상세에서 코멘트로 공유 바랍니다.</p>
</div>
</div>
</div>
<!-- 체크리스트 -->
<div class="field">
<div class="check-head">
<div class="field-label" style="margin-bottom:0">해야 할 일</div>
<div class="progress-wrap">
<div class="progress-bar"><i style="width:40%"></i></div>
<span class="progress-txt">2 / 5 완료</span>
</div>
</div>
<div class="checklist">
<div class="check-item done">
<span class="grip"></span>
<span class="cbox done"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
<span class="txt">캠페인 핵심 메시지 3개 시안 작성</span>
<span class="row-del"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18M6 6l12 12"/></svg></span>
</div>
<div class="check-item done">
<span class="grip"></span>
<span class="cbox done"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
<span class="txt">레퍼런스 리서치 및 무드보드 정리</span>
<span class="row-del"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18M6 6l12 12"/></svg></span>
</div>
<div class="check-item">
<span class="grip"></span>
<span class="cbox"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
<span class="txt">메인 배너 이미지 2종 (가로/세로) 디자인</span>
<span class="row-del"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18M6 6l12 12"/></svg></span>
</div>
<div class="check-item">
<span class="grip"></span>
<span class="cbox"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
<span class="txt">상세페이지 카피라이팅 및 법무 검토</span>
<span class="row-del"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18M6 6l12 12"/></svg></span>
</div>
<div class="check-item">
<span class="grip"></span>
<span class="cbox"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
<span class="txt">최종 산출물 공유 드라이브 업로드</span>
<span class="row-del"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18M6 6l12 12"/></svg></span>
</div>
</div>
<div class="add-row"><span class="plus">+</span> 항목 추가</div>
</div>
</section>
<!-- ============ SIDEBAR ============ -->
<aside class="side">
<div class="card side-card">
<!-- 담당자 (열린 드롭다운) -->
<div class="side-field">
<div class="side-label"><span class="req">*</span> 담당자 <span style="color:var(--text-3);font-weight:500">· 3명</span></div>
<div class="combo">
<div class="combo-field">
<span class="chip"><span class="avatar av-a"></span>김서연 <span class="x">×</span></span>
<span class="chip"><span class="avatar av-b"></span>박지훈 <span class="x">×</span></span>
<span class="chip"><span class="avatar av-c"></span>이도윤 <span class="x">×</span></span>
<input class="combo-input" placeholder="이름 검색…" value="">
<span class="combo-caret"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg></span>
</div>
</div>
</div>
</div>
<div class="card side-card">
<!-- 마감기한 -->
<div class="side-field">
<div class="side-label"><span class="req">*</span> 마감기한</div>
<div class="input">
<span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg></span>
2026년 6월 30일 (화) 18:00
</div>
<div class="date-meta"><span class="dot"></span> 마감까지 15일 남음</div>
</div>
<!-- 첨부파일 -->
<div class="side-field">
<div class="side-label">첨부파일 <span style="color:var(--text-3);font-weight:500">· 3개</span></div>
<div class="files">
<div class="file">
<span class="file-ico fi-pdf">PDF</span>
<span class="file-info"><span class="file-name">캠페인_브리프_v2.pdf</span><span class="file-size">2.4 MB</span></span>
<span class="x"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18M6 6l12 12"/></svg></span>
</div>
<div class="file">
<span class="file-ico fi-pdf">PDF</span>
<span class="file-info"><span class="file-name">브랜드_가이드라인.pdf</span><span class="file-size">8.1 MB</span></span>
<span class="x"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18M6 6l12 12"/></svg></span>
</div>
<div class="file">
<span class="file-ico fi-zip">ZIP</span>
<span class="file-info"><span class="file-name">레퍼런스_이미지.zip</span><span class="file-size">34.7 MB</span></span>
<span class="x"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18M6 6l12 12"/></svg></span>
</div>
</div>
<div class="dropzone">파일을 끌어다 놓거나 <b>찾아보기</b></div>
</div>
</div>
<div class="card side-card">
<div class="side-field" style="border:none;padding:0;margin:0">
<div class="side-label">지시자</div>
<div class="issuer">
<span class="avatar av-f"></span>
<span><div class="nm">정태경</div><div class="rl">마케팅 그룹 리드</div></span>
</div>
<div class="side-foot" style="margin-top:12px">지시를 보내면 담당자 3명에게 알림이 전송되고, 업무가 각자의 ‘내 업무’에 추가됩니다.</div>
</div>
</div>
</aside>
</div>
</div>
</body>
</html>
+430
View File
@@ -0,0 +1,430 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>q3-launch-campaign · 멤버 — Relay</title>
<link rel="preconnect" href="https://cdn.jsdelivr.net">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f4f5f7; --panel: #ffffff; --border: #e6e8ec; --border-strong: #d7dae0;
--text: #16181d; --text-2: #5b626e; --text-3: #9298a3;
--accent: #4f46e5; --accent-hover: #4338ca; --accent-weak: #eef0fe; --accent-border: #c7ccfb;
--green: #1f9d57; --green-weak: #e8f6ee; --red: #d6455d; --amber: #b7791f;
--radius: 6px; --radius-sm: 4px; --shadow-sm: 0 1px 2px rgba(20,24,33,.05);
}
html, body { background: var(--bg); color: var(--text); font-family: "Pretendard", -apple-system, system-ui, sans-serif; font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
body { min-height: 100vh; }
/* topbar */
.topbar { height: 52px; background: var(--panel); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 28px; padding: 0 20px; position: sticky; top: 0; z-index: 50; }
.brand { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 15px; letter-spacing: -.2px; }
.brand .logo { width: 26px; height: 26px; border-radius: 7px; background: linear-gradient(135deg, #5b54f0, #4338ca); display: grid; place-items: center; color: #fff; font-size: 15px; font-weight: 800; box-shadow: 0 2px 6px rgba(79,70,229,.35); }
.topnav { display: flex; align-items: center; gap: 2px; }
.topnav a { color: var(--text-2); text-decoration: none; font-size: 13.5px; font-weight: 500; padding: 7px 12px; border-radius: var(--radius-sm); line-height: 1; white-space: nowrap; }
.topnav a:hover { background: #f1f2f4; color: var(--text); }
.topnav a.active { color: var(--accent); background: var(--accent-weak); font-weight: 600; }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.icon-btn { width: 32px; height: 32px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-2); display: grid; place-items: center; cursor: pointer; }
.icon-btn:hover { background: #f1f2f4; color: var(--text); }
.me { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-size: 11.5px; font-weight: 700; color: #fff; background: #0ea5a3; margin-left: 4px; }
/* page */
.page { max-width: 1080px; margin: 0 auto; padding: 0 24px 70px; }
.crumb { display: flex; align-items: center; gap: 7px; color: var(--text-3); font-size: 12.5px; padding: 18px 0 12px; white-space: nowrap; }
.crumb .lnk { color: var(--text-2); font-weight: 600; text-decoration: none; }
.crumb .lnk:hover { color: var(--accent); }
.crumb .sep { opacity: .5; }
.crumb b { color: var(--text-2); font-weight: 600; }
/* btn */
.btn { height: 36px; padding: 0 15px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; border: 1px solid var(--border-strong); background: var(--panel); color: var(--text-2); cursor: pointer; display: inline-flex; align-items: center; gap: 6px; line-height: 1; text-decoration: none; white-space: nowrap; }
.btn:hover { background: #f7f8fa; color: var(--text); }
.btn svg { width: 15px; height: 15px; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
.btn.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
/* repo header */
.repo-head { display: flex; align-items: flex-start; gap: 16px; background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 20px 22px; box-shadow: var(--shadow-sm); }
.repo-ico { width: 46px; height: 46px; border-radius: 10px; background: var(--accent-weak); color: var(--accent); display: grid; place-items: center; flex-shrink: 0; }
.repo-ico svg { width: 24px; height: 24px; }
.rh-main { flex: 1; min-width: 0; }
.rh-title { display: flex; align-items: center; gap: 10px; }
.rh-name { font-size: 18px; font-weight: 700; letter-spacing: -.3px; white-space: nowrap; }
.rh-name .owner { color: var(--text-3); font-weight: 500; }
.rh-slug { font-size: 12.5px; color: var(--text-3); margin-top: 5px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.vis { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 20px; display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; line-height: 1.5; }
.vis svg { width: 11px; height: 11px; }
.vis.private { color: var(--text-2); background: #f1f2f4; border: 1px solid var(--border); }
.rh-desc { font-size: 13.5px; color: var(--text-2); margin-top: 6px; }
.rh-meta { display: flex; align-items: center; gap: 15px; margin-top: 12px; font-size: 12px; color: var(--text-3); }
.rh-meta .mi { display: flex; align-items: center; gap: 5px; white-space: nowrap; }
.rh-meta .mi svg { width: 13px; height: 13px; }
.rh-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.avatar { width: 21px; height: 21px; border-radius: 50%; display: grid; place-items: center; font-size: 10px; font-weight: 700; color: #fff; flex-shrink: 0; }
.avstack { display: flex; align-items: center; }
.avstack .avatar { margin-left: -7px; box-shadow: 0 0 0 2px #fff; }
.avstack .avatar:first-child { margin-left: 0; }
.av-a { background: #e0518d; } .av-b { background: #3d8bf2; } .av-c { background: #16a37b; }
.av-d { background: #b3631f; } .av-e { background: #7c5cf0; } .av-f { background: #d0982a; }
.av-more { background: #eceef1; color: var(--text-2); box-shadow: 0 0 0 2px #fff; }
/* tabs */
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); margin: 20px 0 6px; }
.tabs a { padding: 10px 14px; font-size: 13.5px; font-weight: 600; color: var(--text-2); text-decoration: none; border-bottom: 2px solid transparent; margin-bottom: -1px; display: flex; align-items: center; gap: 6px; white-space: nowrap; }
.tabs a:hover { color: var(--text); }
.tabs a.active { color: var(--accent); border-bottom-color: var(--accent); }
.tabs a .tb-label { line-height: 1; }
.tabs a .tb-count { font-size: 11px; font-weight: 600; color: var(--text-2); background: #f1f2f4; border-radius: 20px; padding: 0 7px; height: 17px; min-width: 17px; display: inline-flex; align-items: center; justify-content: center; line-height: 1; }
.tabs a.active .tb-count { background: var(--accent-weak); color: var(--accent); }
.tab-note { font-size: 12.5px; color: var(--text-3); margin: 12px 0 16px; display: flex; align-items: center; gap: 7px; }
.tab-note svg { width: 14px; height: 14px; color: var(--text-3); flex-shrink: 0; }
/* toolbar */
.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.search { display: flex; align-items: center; gap: 8px; height: 36px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 11px; background: #fff; width: 240px; }
.search:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
.search svg { width: 16px; height: 16px; color: var(--text-3); }
.search input { border: none; outline: none; flex: 1; min-width: 0; font-family: inherit; font-size: 13.5px; background: transparent; }
.search input::placeholder { color: var(--text-3); }
.segmented { display: flex; background: #eceef1; border-radius: var(--radius); padding: 3px; gap: 2px; }
.segmented button { border: none; background: transparent; font-family: inherit; font-size: 13px; font-weight: 600; color: var(--text-2); padding: 5px 12px; border-radius: 4px; cursor: pointer; white-space: nowrap; display: flex; align-items: center; gap: 6px; }
.segmented button.active { background: #fff; color: var(--text); box-shadow: var(--shadow-sm); }
.segmented button .n { font-size: 11px; color: var(--text-3); font-weight: 600; }
.segmented button.active .n { color: var(--accent); }
/* member list */
.list { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow-sm); overflow: hidden; }
.mem-row { display: flex; align-items: center; gap: 14px; padding: 14px 18px; border-top: 1px solid var(--border); }
.mem-row:first-child { border-top: none; }
.mem-row:hover { background: #fafbfc; }
.mem-av { width: 38px; height: 38px; border-radius: 50%; display: grid; place-items: center; font-size: 14px; font-weight: 700; color: #fff; flex-shrink: 0; }
.mem-info { flex: 1; min-width: 0; }
.mem-name { font-size: 14px; font-weight: 600; color: var(--text); display: flex; align-items: center; gap: 8px; }
.you-pill { font-size: 10.5px; font-weight: 700; color: var(--accent); background: var(--accent-weak); border: 1px solid var(--accent-border); border-radius: 20px; padding: 1px 7px; line-height: 1.5; }
.mem-sub { font-size: 12.5px; color: var(--text-3); margin-top: 2px; }
.mem-sub .dot { margin: 0 6px; opacity: .5; }
.mem-tasks { font-size: 12.5px; color: var(--text-2); width: 86px; text-align: right; flex-shrink: 0; white-space: nowrap; }
.mem-tasks b { color: var(--text); }
.mem-tasks.zero { color: var(--text-3); }
/* role control */
.role { display: inline-flex; align-items: center; gap: 7px; height: 32px; padding: 0 11px; border: 1px solid var(--border-strong); border-radius: var(--radius); font-size: 13px; font-weight: 600; color: var(--text-2); background: #fff; cursor: pointer; white-space: nowrap; width: 104px; }
.role:hover { background: #f7f8fa; }
.role .role-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--text-3); flex-shrink: 0; }
.role.admin { color: var(--accent); border-color: var(--accent-border); background: var(--accent-weak); }
.role.admin .role-dot { background: var(--accent); }
.role .caret { margin-left: auto; color: var(--text-3); display: grid; place-items: center; }
.role .caret svg { width: 14px; height: 14px; }
.role.fixed { background: #f1f2f4; cursor: not-allowed; color: var(--text-2); border-color: var(--border-strong); }
.role.fixed .role-dot { background: var(--accent); }
.role.fixed .caret { color: var(--text-3); }
.role.fixed .lock { margin-left: auto; color: var(--text-3); display: grid; place-items: center; }
.role.fixed .lock svg { width: 13px; height: 13px; }
.mem-remove { width: 32px; height: 32px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-3); display: grid; place-items: center; cursor: pointer; flex-shrink: 0; opacity: 0; }
.mem-row:hover .mem-remove { opacity: 1; }
.mem-remove:hover { background: #f1f2f4; color: var(--red); }
.mem-remove svg { width: 16px; height: 16px; }
.mem-remove.disabled { opacity: 0 !important; pointer-events: none; }
/* invite modal */
.modal-backdrop { position: fixed; inset: 0; background: rgba(20,24,33,.5); display: none; align-items: center; justify-content: center; z-index: 100; padding: 24px; }
.modal-backdrop.open { display: flex; }
.modal { width: 100%; max-width: 524px; background: #fff; border-radius: 14px; box-shadow: 0 24px 64px rgba(20,24,33,.32); overflow: hidden; animation: pop .14s ease-out; }
@keyframes pop { from { transform: translateY(8px) scale(.98); opacity: 0; } to { transform: none; opacity: 1; } }
.modal-head { display: flex; align-items: flex-start; gap: 12px; padding: 22px 24px 16px; }
.modal-head h2 { font-size: 18px; font-weight: 700; letter-spacing: -.3px; }
.modal-head .mh-sub { font-size: 13px; color: var(--text-2); margin-top: 4px; }
.modal-close { margin-left: auto; width: 32px; height: 32px; border: none; background: transparent; color: var(--text-3); border-radius: var(--radius-sm); display: grid; place-items: center; cursor: pointer; flex-shrink: 0; }
.modal-close:hover { background: #f1f2f4; color: var(--text); }
.modal-close svg { width: 18px; height: 18px; }
.modal-body { padding: 4px 24px 8px; }
.m-label { font-size: 12.5px; font-weight: 600; color: var(--text-2); margin-bottom: 8px; display: block; }
.icombo { position: relative; }
.icombo-field { border: 1px solid var(--accent); border-radius: var(--radius); padding: 7px 8px; background: #fff; box-shadow: 0 0 0 3px var(--accent-weak); display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.ichip { display: inline-flex; align-items: center; gap: 6px; background: #f1f2f4; border: 1px solid var(--border); border-radius: 20px; padding: 3px 8px 3px 4px; font-size: 12.5px; font-weight: 600; color: var(--text); white-space: nowrap; }
.ichip.ext { background: var(--accent-weak); border-color: var(--accent-border); color: var(--accent); }
.ichip .x { color: var(--text-3); cursor: pointer; font-size: 13px; line-height: 1; }
.ichip .x:hover { color: var(--red); }
.icombo-input { flex: 1; min-width: 120px; border: none; outline: none; font-family: inherit; font-size: 13px; padding: 4px 2px; background: transparent; }
.icombo-input::placeholder { color: var(--text-3); }
.idd { display: none; position: absolute; top: calc(100% + 6px); left: 0; right: 0; z-index: 5; background: #fff; border: 1px solid var(--border); border-radius: 9px; box-shadow: 0 12px 32px rgba(20,24,33,.16); padding: 6px; }
.icombo .idd.show { display: block; }
.idd-cap { font-size: 11px; color: var(--text-3); font-weight: 600; padding: 5px 8px; }
.idd-item { display: flex; align-items: center; gap: 10px; padding: 7px 8px; border-radius: var(--radius-sm); cursor: pointer; }
.idd-item:hover { background: #f5f6f8; }
.idd-item .nm { font-size: 13px; font-weight: 600; white-space: nowrap; }
.idd-item .em { font-size: 12px; color: var(--text-3); margin-left: auto; white-space: nowrap; flex-shrink: 0; }
.idd-invite { display: flex; align-items: center; gap: 10px; padding: 8px; border-radius: var(--radius-sm); cursor: pointer; border-top: 1px solid var(--border); margin-top: 4px; }
.idd-invite:hover { background: var(--accent-weak); }
.idd-invite .pl { width: 26px; height: 26px; border-radius: 50%; background: var(--accent-weak); color: var(--accent); display: grid; place-items: center; flex-shrink: 0; }
.idd-invite .pl svg { width: 15px; height: 15px; }
.idd-invite .t1 { font-size: 13px; font-weight: 600; color: var(--accent); }
.idd-invite .t2 { font-size: 11.5px; color: var(--text-3); }
.m-row { display: flex; gap: 12px; margin-top: 18px; }
.m-role { flex: 0 0 150px; }
.m-grow { flex: 1; }
.select-wrap { position: relative; }
.select-wrap select { appearance: none; -webkit-appearance: none; width: 100%; height: 40px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 34px 0 12px; font-family: inherit; font-size: 13.5px; font-weight: 600; color: var(--text); background: #fff; cursor: pointer; }
.select-wrap select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
.select-wrap .caret { position: absolute; right: 11px; top: 50%; transform: translateY(-50%); color: var(--text-3); pointer-events: none; display: grid; place-items: center; }
.select-wrap .caret svg { width: 15px; height: 15px; }
.m-input { width: 100%; height: 40px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 12px; font-family: inherit; font-size: 13.5px; color: var(--text); background: #fff; }
.m-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
.m-input::placeholder { color: var(--text-3); }
.role-hint { font-size: 12px; color: var(--text-3); margin-top: 8px; line-height: 1.5; }
.modal-foot { display: flex; align-items: center; gap: 9px; padding: 16px 24px; border-top: 1px solid var(--border); background: #fafbfc; margin-top: 16px; }
.modal-foot .fnote { font-size: 12px; color: var(--text-3); margin-right: auto; }
.mbtn { height: 38px; padding: 0 16px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; border: 1px solid var(--border-strong); background: #fff; color: var(--text-2); cursor: pointer; display: inline-flex; align-items: center; gap: 6px; }
.mbtn:hover { background: #f1f2f4; color: var(--text); }
.mbtn.primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
.mbtn.primary:hover { background: var(--accent-hover); }
.mbtn svg { width: 15px; height: 15px; }
</style>
</head>
<body>
<header class="topbar">
<div class="brand"><div class="logo">R</div>Relay</div>
<nav class="topnav">
<a href="내 업무.html">내 업무</a>
<a href="저장소 목록.html" class="active">저장소</a>
</nav>
<div class="topbar-right">
<button class="icon-btn" title="검색"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg></button>
<button class="icon-btn" title="알림"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg></button>
<div class="me"></div>
</div>
</header>
<div class="page" data-screen-label="저장소 멤버">
<div class="crumb">
<a href="저장소 목록.html" class="lnk">저장소</a>
<span class="sep">/</span>
<a href="저장소 상세.html" class="lnk">3분기 신제품 런칭 캘페인</a>
<span class="sep">/</span>
<b>멤버</b>
</div>
<!-- 저장소 헤더 -->
<div class="repo-head">
<div class="repo-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg></div>
<div class="rh-main">
<div class="rh-title">
<span class="rh-name">3분기 신제품 런칭 캘페인</span>
<span class="vis private"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>비공개</span>
</div>
<div class="rh-slug">marketing-team / q3-launch-campaign</div>
<div class="rh-desc">3분기 신제품(라인 클렌저) 런칭 캠페인 소재 제작 및 검수</div>
<div class="rh-meta">
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>main</span>
<span class="mi avstack"><span class="avatar av-f"></span><span class="avatar av-a"></span><span class="avatar av-b"></span><span class="avatar av-c"></span><span class="avatar av-d"></span></span>
<span class="mi">멤버 5명</span>
</div>
</div>
<div class="rh-actions">
<a class="btn primary" href="업무 작성.html"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5v14"/></svg>새 업무</a>
</div>
</div>
<!-- 서브탭 -->
<nav class="tabs">
<a href="저장소 상세.html"><span class="tb-label">업무</span> <span class="tb-count">12</span></a>
<a href="#" class="active"><span class="tb-label">멤버</span> <span class="tb-count">5</span></a>
<a href="저장소 활동.html"><span class="tb-label">활동</span></a>
<a href="저장소 설정.html"><span class="tb-label">설정</span></a>
</nav>
<div class="tab-note">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></svg>
이 저장소의 멤버만 업무 담당자로 지정할 수 있습니다. <b style="color:var(--text-2);font-weight:600;white-space:nowrap">관리자</b>는 업무 지시·멤버 관리가 가능하고, <b style="color:var(--text-2);font-weight:600;white-space:nowrap">멤버</b>는 담당자로 배정되어 업무를 수행합니다.
</div>
<!-- 툴바 -->
<div class="toolbar">
<div class="search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>
<input type="text" placeholder="이름·이메일 검색…">
</div>
<div class="segmented">
<button class="active">전체 <span class="n">5</span></button>
<button>관리자 <span class="n">1</span></button>
<button>멤버 <span class="n">4</span></button>
</div>
<button class="btn primary" id="inviteBtn" type="button" style="margin-left:auto;border:none">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M19 8v6M22 11h-6"/></svg>
멤버 초대
</button>
</div>
<!-- 멤버 목록 -->
<div class="list">
<!-- 관리자 (소유자·현재 사용자) -->
<div class="mem-row">
<span class="mem-av av-f"></span>
<div class="mem-info">
<div class="mem-name">정태경 <span class="you-pill"></span></div>
<div class="mem-sub">tkjung@acme.co <span class="dot">·</span> 리드</div>
</div>
<div class="mem-tasks"><b>1</b>건 담당</div>
<div class="role fixed admin">
<span class="role-dot"></span>관리자
<span class="lock"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>
</div>
<button class="mem-remove disabled" title="소유자는 제거할 수 없습니다"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
</div>
<!-- 멤버 -->
<div class="mem-row">
<span class="mem-av av-a"></span>
<div class="mem-info">
<div class="mem-name">김서연</div>
<div class="mem-sub">sykim@acme.co <span class="dot">·</span> 콘텐츠 디자인</div>
</div>
<div class="mem-tasks"><b>2</b>건 담당</div>
<div class="role">
<span class="role-dot"></span>멤버
<span class="caret"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg></span>
</div>
<button class="mem-remove" title="멤버 제거"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
</div>
<div class="mem-row">
<span class="mem-av av-b"></span>
<div class="mem-info">
<div class="mem-name">박지훈</div>
<div class="mem-sub">jhpark@acme.co <span class="dot">·</span> 퍼포먼스 마케팅</div>
</div>
<div class="mem-tasks"><b>2</b>건 담당</div>
<div class="role">
<span class="role-dot"></span>멤버
<span class="caret"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg></span>
</div>
<button class="mem-remove" title="멤버 제거"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
</div>
<div class="mem-row">
<span class="mem-av av-c"></span>
<div class="mem-info">
<div class="mem-name">이도윤</div>
<div class="mem-sub">dylee@acme.co <span class="dot">·</span> 카피라이터</div>
</div>
<div class="mem-tasks"><b>2</b>건 담당</div>
<div class="role">
<span class="role-dot"></span>멤버
<span class="caret"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg></span>
</div>
<button class="mem-remove" title="멤버 제거"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
</div>
<div class="mem-row">
<span class="mem-av av-d"></span>
<div class="mem-info">
<div class="mem-name">최유진</div>
<div class="mem-sub">yjchoi@acme.co <span class="dot">·</span> UX 디자인</div>
</div>
<div class="mem-tasks"><b>1</b>건 담당</div>
<div class="role">
<span class="role-dot"></span>멤버
<span class="caret"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg></span>
</div>
<button class="mem-remove" title="멤버 제거"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
</div>
</div>
</div>
<!-- 멤버 초대 모달 -->
<div class="modal-backdrop" id="inviteModal">
<div class="modal" role="dialog" aria-modal="true">
<div class="modal-head">
<div>
<h2>멤버 초대</h2>
<div class="mh-sub"><b style="color:var(--text-2);font-weight:600">3분기 신제품 런칭 캠페인</b> 저장소에 멤버를 추가합니다.</div>
</div>
<button class="modal-close" id="inviteClose" type="button"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
</div>
<div class="modal-body">
<label class="m-label">초대할 사람</label>
<div class="icombo">
<div class="icombo-field">
<span class="ichip"><span class="avatar av-f" style="width:18px;height:18px;font-size:9px"></span>한지민 <span class="x">×</span></span>
<span class="ichip ext"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 6-10 7L2 6"/></svg>partner@vendor.com <span class="x">×</span></span>
<input class="icombo-input" placeholder="이름 또는 이메일 입력…">
</div>
<div class="idd">
<div class="idd-cap">조직 멤버 · 이 저장소에 없는 사람</div>
<div class="idd-item">
<span class="avatar av-b" style="width:26px;height:26px;font-size:11px"></span>
<span class="nm">오세훈</span><span class="em">sehoon.oh@acme.co</span>
</div>
<div class="idd-item">
<span class="avatar av-c" style="width:26px;height:26px;font-size:11px"></span>
<span class="nm">강민서</span><span class="em">minseo.kang@acme.co</span>
</div>
<div class="idd-item">
<span class="avatar av-e" style="width:26px;height:26px;font-size:11px"></span>
<span class="nm">윤하늘</span><span class="em">haneul.yoon@acme.co</span>
</div>
<div class="idd-invite">
<span class="pl"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5v14"/></svg></span>
<span><div class="t1">이메일로 외부 초대</div><div class="t2">조직에 없는 사람도 이메일로 초대할 수 있어요</div></span>
</div>
</div>
</div>
<div class="m-row">
<div class="m-role">
<label class="m-label">역할</label>
<div class="select-wrap">
<select>
<option>멤버</option>
<option>관리자</option>
</select>
<span class="caret"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg></span>
</div>
</div>
<div class="m-grow">
<label class="m-label">초대 메시지 <span style="color:var(--text-3);font-weight:500">(선택)</span></label>
<input class="m-input" type="text" placeholder="함께 캠페인 준비해요!">
</div>
</div>
<div class="role-hint"><b style="color:var(--text-2);font-weight:600">멤버</b>는 담당자로 배정되어 업무를 수행하고, <b style="color:var(--text-2);font-weight:600">관리자</b>는 업무 지시와 멤버 관리도 할 수 있습니다.</div>
</div>
<div class="modal-foot">
<span class="fnote">초대받은 사람에게 메일이 발송됩니다.</span>
<button class="mbtn" id="inviteCancel" type="button">취소</button>
<button class="mbtn primary" type="button">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/></svg>
초대 보내기 (2)
</button>
</div>
</div>
</div>
<script>
var modal = document.getElementById('inviteModal');
function openModal() { modal.classList.add('open'); }
function closeModal() { modal.classList.remove('open'); }
document.getElementById('inviteBtn').addEventListener('click', openModal);
document.getElementById('inviteClose').addEventListener('click', closeModal);
document.getElementById('inviteCancel').addEventListener('click', closeModal);
modal.addEventListener('click', function (e) { if (e.target === modal) closeModal(); });
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeModal(); });
var iInput = document.querySelector('.icombo-input');
var idd = document.querySelector('.idd');
iInput.addEventListener('focus', function () { idd.classList.add('show'); });
iInput.addEventListener('blur', function () { setTimeout(function () { idd.classList.remove('show'); }, 120); });
</script>
</body>
</html>
+301
View File
@@ -0,0 +1,301 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>저장소 — Relay</title>
<link rel="preconnect" href="https://cdn.jsdelivr.net">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f4f5f7; --panel: #ffffff; --border: #e6e8ec; --border-strong: #d7dae0;
--text: #16181d; --text-2: #5b626e; --text-3: #9298a3;
--accent: #4f46e5; --accent-hover: #4338ca; --accent-weak: #eef0fe; --accent-border: #c7ccfb;
--green: #1f9d57; --green-weak: #e8f6ee; --amber: #b7791f;
--radius: 6px; --radius-sm: 4px;
--shadow-sm: 0 1px 2px rgba(20,24,33,.05);
}
html, body { background: var(--bg); color: var(--text); font-family: "Pretendard", -apple-system, system-ui, sans-serif; font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
body { min-height: 100vh; }
/* topbar */
.topbar { height: 52px; background: var(--panel); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 28px; padding: 0 20px; position: sticky; top: 0; z-index: 50; }
.brand { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 15px; letter-spacing: -.2px; }
.brand .logo { width: 26px; height: 26px; border-radius: 7px; background: linear-gradient(135deg, #5b54f0, #4338ca); display: grid; place-items: center; color: #fff; font-size: 15px; font-weight: 800; box-shadow: 0 2px 6px rgba(79,70,229,.35); }
.topnav { display: flex; align-items: center; gap: 2px; }
.topnav a { color: var(--text-2); text-decoration: none; font-size: 13.5px; font-weight: 500; padding: 7px 12px; border-radius: var(--radius-sm); line-height: 1; white-space: nowrap; }
.topnav a:hover { background: #f1f2f4; color: var(--text); }
.topnav a.active { color: var(--accent); background: var(--accent-weak); font-weight: 600; }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.icon-btn { width: 32px; height: 32px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-2); display: grid; place-items: center; cursor: pointer; }
.icon-btn:hover { background: #f1f2f4; color: var(--text); }
.me { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-size: 11.5px; font-weight: 700; color: #fff; background: #0ea5a3; margin-left: 4px; }
/* page */
.page { max-width: 1080px; margin: 0 auto; padding: 0 24px 70px; }
.crumb { display: flex; align-items: center; gap: 7px; color: var(--text-3); font-size: 12.5px; padding: 18px 0 0; white-space: nowrap; }
.crumb b { color: var(--text-2); font-weight: 600; }
.pagehead { display: flex; align-items: center; gap: 11px; padding: 9px 0 4px; }
.pagehead h1 { font-size: 22px; font-weight: 700; letter-spacing: -.4px; white-space: nowrap; }
.count-pill { font-size: 13px; font-weight: 600; color: var(--text-2); background: #f1f2f4; border: 1px solid var(--border); border-radius: 20px; padding: 2px 11px; }
.gitea-badge { margin-left: auto; display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600; color: #1b7a3d; background: var(--green-weak); border: 1px solid #cde8d8; padding: 4px 10px; border-radius: 20px; line-height: 1; white-space: nowrap; }
.gitea-badge svg { width: 13px; height: 13px; }
.lede { color: var(--text-2); font-size: 13.5px; margin: 4px 0 18px; }
/* btn */
.btn { height: 36px; padding: 0 15px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; border: 1px solid var(--border-strong); background: var(--panel); color: var(--text-2); cursor: pointer; display: inline-flex; align-items: center; gap: 6px; line-height: 1; text-decoration: none; white-space: nowrap; }
.btn:hover { background: #f7f8fa; color: var(--text); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
.btn.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
/* toolbar */
.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.search { display: flex; align-items: center; gap: 8px; height: 36px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 11px; background: #fff; width: 280px; }
.search:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
.search svg { width: 16px; height: 16px; color: var(--text-3); }
.search input { border: none; outline: none; flex: 1; min-width: 0; font-family: inherit; font-size: 13.5px; background: transparent; }
.search input::placeholder { color: var(--text-3); }
.segmented { display: flex; background: #eceef1; border-radius: var(--radius); padding: 3px; gap: 2px; }
.segmented button { border: none; background: transparent; font-family: inherit; font-size: 13px; font-weight: 600; color: var(--text-2); padding: 5px 13px; border-radius: 4px; cursor: pointer; white-space: nowrap; }
.segmented button.active { background: #fff; color: var(--text); box-shadow: var(--shadow-sm); }
.sort { margin-left: auto; display: flex; align-items: center; gap: 6px; height: 36px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 11px; background: #fff; font-size: 13px; font-weight: 500; color: var(--text-2); cursor: pointer; white-space: nowrap; }
.sort svg { width: 15px; height: 15px; color: var(--text-3); }
/* list */
.list { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow-sm); overflow: hidden; }
.repo-row { display: flex; align-items: center; gap: 16px; padding: 16px 20px; border-top: 1px solid var(--border); cursor: pointer; text-decoration: none; color: inherit; }
.repo-row:first-child { border-top: none; }
.repo-row:hover { background: #fafbfc; }
.repo-ico { width: 40px; height: 40px; border-radius: 9px; background: var(--accent-weak); color: var(--accent); display: grid; place-items: center; flex-shrink: 0; align-self: flex-start; margin-top: 2px; }
.repo-ico svg { width: 21px; height: 21px; }
.repo-main { flex: 1; min-width: 0; }
.repo-title { display: flex; align-items: center; column-gap: 9px; row-gap: 2px; flex-wrap: wrap; }
.repo-name { font-size: 15px; font-weight: 600; color: var(--text); white-space: nowrap; }
.repo-name .owner { color: var(--text-3); font-weight: 500; }
.repo-slug { order: 2; flex-basis: 100%; font-size: 12px; font-weight: 500; color: var(--text-3); margin-top: 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.repo-row:hover .repo-name { color: var(--accent); }
.vis { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 20px; display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; line-height: 1.5; }
.vis svg { width: 11px; height: 11px; }
.vis.private { color: var(--text-2); background: #f1f2f4; border: 1px solid var(--border); }
.vis.public { color: #1b7a3d; background: var(--green-weak); border: 1px solid #cde8d8; }
.repo-desc { font-size: 13px; color: var(--text-2); margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 480px; }
.repo-meta { display: flex; align-items: center; gap: 15px; margin-top: 10px; font-size: 12px; color: var(--text-3); }
.repo-meta .mi { display: flex; align-items: center; gap: 5px; white-space: nowrap; }
.repo-meta .mi svg { width: 13px; height: 13px; }
.avstack { display: flex; align-items: center; }
.avstack .avatar { margin-left: -7px; box-shadow: 0 0 0 2px #fff; }
.avstack .avatar:first-child { margin-left: 0; }
.avatar { width: 21px; height: 21px; border-radius: 50%; display: grid; place-items: center; font-size: 10px; font-weight: 700; color: #fff; flex-shrink: 0; }
.av-a { background: #e0518d; } .av-b { background: #3d8bf2; } .av-c { background: #16a37b; }
.av-d { background: #b3631f; } .av-e { background: #7c5cf0; } .av-f { background: #d0982a; }
.av-more { background: #eceef1; color: var(--text-2); box-shadow: 0 0 0 2px #fff; }
.repo-stat { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; width: 168px; flex-shrink: 0; }
.stat-top { font-size: 12.5px; color: var(--text-2); font-weight: 600; white-space: nowrap; }
.stat-top b { color: var(--text); }
.pbar { width: 156px; height: 6px; border-radius: 4px; background: #eceef1; overflow: hidden; }
.pbar > i { display: block; height: 100%; border-radius: 4px; background: var(--green); }
.pbar > i.mid { background: var(--accent); }
.pbar > i.low { background: var(--amber); }
.stat-pct { font-size: 11.5px; color: var(--text-3); white-space: nowrap; }
.chev { color: var(--text-3); flex-shrink: 0; display: grid; place-items: center; }
.chev svg { width: 18px; height: 18px; }
</style>
</head>
<body>
<header class="topbar">
<div class="brand"><div class="logo">R</div>Relay</div>
<nav class="topnav">
<a href="내 업무.html">내 업무</a>
<a href="저장소 목록.html" class="active">저장소</a>
</nav>
<div class="topbar-right">
<button class="icon-btn" title="검색"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg></button>
<button class="icon-btn" title="알림"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg></button>
<div class="me"></div>
</div>
</header>
<div class="page" data-screen-label="저장소 목록">
<div class="crumb"><b>저장소</b></div>
<div class="pagehead">
<h1>저장소</h1>
<span class="count-pill">6</span>
<span class="gitea-badge"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 17H7A5 5 0 0 1 7 7h2"/><path d="M15 7h2a5 5 0 0 1 0 10h-2"/><path d="M8 12h8"/></svg> Gitea 연동됨</span>
</div>
<p class="lede"><b style="color:var(--text-2)">marketing-team</b> 조직의 저장소입니다. 저장소를 열어 업무를 작성하고 담당자에게 전달하세요.</p>
<div class="toolbar">
<div class="search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>
<input type="text" placeholder="저장소 검색…">
</div>
<div class="segmented">
<button class="active">전체</button>
<button>비공개</button>
<button>공개</button>
</div>
<div class="sort">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5h10M11 9h7M11 13h4M3 17l3 3 3-3M6 18V4"/></svg>
최근 업데이트순
</div>
<a class="btn primary" href="저장소 생성.html">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5v14"/></svg>
새 저장소
</a>
</div>
<div class="list">
<!-- 1 -->
<a class="repo-row" href="저장소 상세.html">
<div class="repo-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg></div>
<div class="repo-main">
<div class="repo-title">
<span class="repo-name">3분기 신제품 런칭 캘페인</span>
<span class="repo-slug">marketing-team/q3-launch-campaign</span>
<span class="vis private"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>비공개</span>
</div>
<div class="repo-desc">3분기 신제품(라인 클렌저) 런칭 캠페인 소재 제작 및 검수</div>
<div class="repo-meta">
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>main</span>
<span class="mi avstack"><span class="avatar av-a"></span><span class="avatar av-b"></span><span class="avatar av-c"></span><span class="avatar av-more">+2</span></span>
<span class="mi">2시간 전 업데이트</span>
</div>
</div>
<div class="repo-stat">
<div class="stat-top"><b>8</b> / 12 업무</div>
<div class="pbar"><i class="mid" style="width:67%"></i></div>
<div class="stat-pct">67% 완료</div>
</div>
<span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></span>
</a>
<!-- 2 -->
<a class="repo-row" href="저장소 상세.html">
<div class="repo-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg></div>
<div class="repo-main">
<div class="repo-title">
<span class="repo-name">브랜드 리뉴얼 2026</span>
<span class="repo-slug">marketing-team/brand-refresh-2026</span>
<span class="vis private"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>비공개</span>
</div>
<div class="repo-desc">브랜드 아이덴티티 리뉴얼 및 가이드라인 개편 프로젝트</div>
<div class="repo-meta">
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>main</span>
<span class="mi avstack"><span class="avatar av-d"></span><span class="avatar av-e"></span><span class="avatar av-f"></span><span class="avatar av-more">+1</span></span>
<span class="mi">어제 업데이트</span>
</div>
</div>
<div class="repo-stat">
<div class="stat-top"><b>5</b> / 9 업무</div>
<div class="pbar"><i class="mid" style="width:56%"></i></div>
<div class="stat-pct">56% 완료</div>
</div>
<span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></span>
</a>
<!-- 3 -->
<a class="repo-row" href="저장소 상세.html">
<div class="repo-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg></div>
<div class="repo-main">
<div class="repo-title">
<span class="repo-name">월간 뉴스레터 운영</span>
<span class="repo-slug">marketing-team/monthly-newsletter</span>
<span class="vis public"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15 15 0 0 1 0 20M12 2a15 15 0 0 0 0 20"/></svg>공개</span>
</div>
<div class="repo-desc">월간 뉴스레터 기획 · 콘텐츠 작성 · 발송 정기 운영</div>
<div class="repo-meta">
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>main</span>
<span class="mi avstack"><span class="avatar av-c"></span><span class="avatar av-b"></span><span class="avatar av-a"></span></span>
<span class="mi">3일 전 업데이트</span>
</div>
</div>
<div class="repo-stat">
<div class="stat-top"><b>14</b> / 14 업무</div>
<div class="pbar"><i style="width:100%"></i></div>
<div class="stat-pct">100% 완료</div>
</div>
<span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></span>
</a>
<!-- 4 -->
<a class="repo-row" href="저장소 상세.html">
<div class="repo-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg></div>
<div class="repo-main">
<div class="repo-title">
<span class="repo-name">SEO 콘텐츠 허브</span>
<span class="repo-slug">marketing-team/seo-content-hub</span>
<span class="vis private"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>비공개</span>
</div>
<div class="repo-desc">검색 유입 강화를 위한 SEO 콘텐츠 기획 및 제작 허브</div>
<div class="repo-meta">
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>main</span>
<span class="mi avstack"><span class="avatar av-b"></span><span class="avatar av-f"></span><span class="avatar av-c"></span><span class="avatar av-more">+3</span></span>
<span class="mi">5시간 전 업데이트</span>
</div>
</div>
<div class="repo-stat">
<div class="stat-top"><b>2</b> / 11 업무</div>
<div class="pbar"><i class="low" style="width:18%"></i></div>
<div class="stat-pct">18% 완료</div>
</div>
<span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></span>
</a>
<!-- 5 -->
<a class="repo-row" href="저장소 상세.html">
<div class="repo-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg></div>
<div class="repo-main">
<div class="repo-title">
<span class="repo-name">사내 행사 핵드북</span>
<span class="repo-slug">marketing-team/event-handbook</span>
<span class="vis public"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15 15 0 0 1 0 20M12 2a15 15 0 0 0 0 20"/></svg>공개</span>
</div>
<div class="repo-desc">사내 행사 운영 가이드 및 준비 체크리스트 모음</div>
<div class="repo-meta">
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>main</span>
<span class="mi avstack"><span class="avatar av-e"></span><span class="avatar av-a"></span></span>
<span class="mi">1주 전 업데이트</span>
</div>
</div>
<div class="repo-stat">
<div class="stat-top"><b>0</b> / 4 업무</div>
<div class="pbar"><i class="low" style="width:0%"></i></div>
<div class="stat-pct">시작 전</div>
</div>
<span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></span>
</a>
<!-- 6 -->
<a class="repo-row" href="저장소 상세.html">
<div class="repo-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg></div>
<div class="repo-main">
<div class="repo-title">
<span class="repo-name">디자인 시스템 에셋</span>
<span class="repo-slug">marketing-team/design-system-assets</span>
<span class="vis private"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>비공개</span>
</div>
<div class="repo-desc">공통 디자인 시스템 에셋 및 컴포넌트 관리</div>
<div class="repo-meta">
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>develop</span>
<span class="mi avstack"><span class="avatar av-a"></span><span class="avatar av-d"></span><span class="avatar av-c"></span><span class="avatar av-more">+1</span></span>
<span class="mi">30분 전 업데이트</span>
</div>
</div>
<div class="repo-stat">
<div class="stat-top"><b>9</b> / 10 업무</div>
<div class="pbar"><i style="width:90%"></i></div>
<div class="stat-pct">90% 완료</div>
</div>
<span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></span>
</a>
</div>
</div>
</body>
</html>
+350
View File
@@ -0,0 +1,350 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>q3-launch-campaign — Relay</title>
<link rel="preconnect" href="https://cdn.jsdelivr.net">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f4f5f7; --panel: #ffffff; --border: #e6e8ec; --border-strong: #d7dae0;
--text: #16181d; --text-2: #5b626e; --text-3: #9298a3;
--accent: #4f46e5; --accent-hover: #4338ca; --accent-weak: #eef0fe; --accent-border: #c7ccfb;
--green: #1f9d57; --green-weak: #e8f6ee; --blue: #1d4ed8; --blue-weak: #e8efff; --blue-border: #c9dbff;
--red: #d6455d; --red-weak: #fdecef; --amber: #b7791f; --amber-weak: #fdf4e3; --amber-border: #f3e2bd;
--radius: 6px; --radius-sm: 4px; --shadow-sm: 0 1px 2px rgba(20,24,33,.05);
}
html, body { background: var(--bg); color: var(--text); font-family: "Pretendard", -apple-system, system-ui, sans-serif; font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
body { min-height: 100vh; }
/* topbar */
.topbar { height: 52px; background: var(--panel); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 28px; padding: 0 20px; position: sticky; top: 0; z-index: 50; }
.brand { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 15px; letter-spacing: -.2px; }
.brand .logo { width: 26px; height: 26px; border-radius: 7px; background: linear-gradient(135deg, #5b54f0, #4338ca); display: grid; place-items: center; color: #fff; font-size: 15px; font-weight: 800; box-shadow: 0 2px 6px rgba(79,70,229,.35); }
.topnav { display: flex; align-items: center; gap: 2px; }
.topnav a { color: var(--text-2); text-decoration: none; font-size: 13.5px; font-weight: 500; padding: 7px 12px; border-radius: var(--radius-sm); line-height: 1; white-space: nowrap; }
.topnav a:hover { background: #f1f2f4; color: var(--text); }
.topnav a.active { color: var(--accent); background: var(--accent-weak); font-weight: 600; }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.icon-btn { width: 32px; height: 32px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-2); display: grid; place-items: center; cursor: pointer; }
.icon-btn:hover { background: #f1f2f4; color: var(--text); }
.me { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-size: 11.5px; font-weight: 700; color: #fff; background: #0ea5a3; margin-left: 4px; }
/* page */
.page { max-width: 1080px; margin: 0 auto; padding: 0 24px 70px; }
.crumb { display: flex; align-items: center; gap: 7px; color: var(--text-3); font-size: 12.5px; padding: 18px 0 12px; white-space: nowrap; }
.crumb .lnk { color: var(--text-2); font-weight: 600; text-decoration: none; }
.crumb .lnk:hover { color: var(--accent); }
.crumb .sep { opacity: .5; }
.crumb b { color: var(--text-2); font-weight: 600; }
/* btn */
.btn { height: 36px; padding: 0 15px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; border: 1px solid var(--border-strong); background: var(--panel); color: var(--text-2); cursor: pointer; display: inline-flex; align-items: center; gap: 6px; line-height: 1; text-decoration: none; white-space: nowrap; }
.btn:hover { background: #f7f8fa; color: var(--text); }
.btn svg { width: 15px; height: 15px; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
.btn.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
/* repo header */
.repo-head { display: flex; align-items: flex-start; gap: 16px; background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 20px 22px; box-shadow: var(--shadow-sm); }
.repo-ico { width: 46px; height: 46px; border-radius: 10px; background: var(--accent-weak); color: var(--accent); display: grid; place-items: center; flex-shrink: 0; }
.repo-ico svg { width: 24px; height: 24px; }
.rh-main { flex: 1; min-width: 0; }
.rh-title { display: flex; align-items: center; gap: 10px; }
.rh-name { font-size: 18px; font-weight: 700; letter-spacing: -.3px; white-space: nowrap; }
.rh-name .owner { color: var(--text-3); font-weight: 500; }
.rh-slug { font-size: 12.5px; color: var(--text-3); margin-top: 5px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.vis { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 20px; display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; line-height: 1.5; }
.vis svg { width: 11px; height: 11px; }
.vis.private { color: var(--text-2); background: #f1f2f4; border: 1px solid var(--border); }
.rh-desc { font-size: 13.5px; color: var(--text-2); margin-top: 6px; }
.rh-meta { display: flex; align-items: center; gap: 15px; margin-top: 12px; font-size: 12px; color: var(--text-3); }
.rh-meta .mi { display: flex; align-items: center; gap: 5px; white-space: nowrap; }
.rh-meta .mi svg { width: 13px; height: 13px; }
.avstack { display: flex; align-items: center; }
.avstack .avatar { margin-left: -7px; box-shadow: 0 0 0 2px #fff; }
.avstack .avatar:first-child { margin-left: 0; }
.avatar { width: 21px; height: 21px; border-radius: 50%; display: grid; place-items: center; font-size: 10px; font-weight: 700; color: #fff; flex-shrink: 0; }
.av-a { background: #e0518d; } .av-b { background: #3d8bf2; } .av-c { background: #16a37b; }
.av-d { background: #b3631f; } .av-e { background: #7c5cf0; } .av-f { background: #d0982a; }
.av-more { background: #eceef1; color: var(--text-2); box-shadow: 0 0 0 2px #fff; }
.rh-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
/* progress strip */
.prog-strip { display: flex; align-items: center; gap: 16px; margin-top: 16px; padding-top: 15px; border-top: 1px solid var(--border); }
.prog-strip .pbar { flex: 1; height: 8px; border-radius: 5px; background: #eceef1; overflow: hidden; max-width: 360px; }
.prog-strip .pbar > i { display: block; height: 100%; border-radius: 5px; background: var(--green); }
.prog-txt { font-size: 13px; font-weight: 600; color: var(--text); white-space: nowrap; }
.prog-txt span { color: var(--text-3); font-weight: 500; }
.prog-warn { font-size: 12.5px; font-weight: 600; color: var(--red); display: flex; align-items: center; gap: 5px; white-space: nowrap; }
.prog-warn svg { width: 14px; height: 14px; }
/* tabs */
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); margin: 20px 0 18px; }
.tabs a { padding: 10px 14px; font-size: 13.5px; font-weight: 600; color: var(--text-2); text-decoration: none; border-bottom: 2px solid transparent; margin-bottom: -1px; display: flex; align-items: center; gap: 6px; white-space: nowrap; }
.tabs a:hover { color: var(--text); }
.tabs a.active { color: var(--accent); border-bottom-color: var(--accent); }
.tabs a .tb-label { line-height: 1; }
.tabs a .tb-count { font-size: 11px; font-weight: 600; color: var(--text-2); background: #f1f2f4; border-radius: 20px; padding: 0 7px; height: 17px; min-width: 17px; display: inline-flex; align-items: center; justify-content: center; line-height: 1; }
.tabs a.active .tb-count { background: var(--accent-weak); color: var(--accent); }
/* toolbar */
.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.search { display: flex; align-items: center; gap: 8px; height: 36px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 11px; background: #fff; width: 240px; }
.search:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
.search svg { width: 16px; height: 16px; color: var(--text-3); }
.search input { border: none; outline: none; flex: 1; min-width: 0; font-family: inherit; font-size: 13.5px; background: transparent; }
.search input::placeholder { color: var(--text-3); }
.segmented { display: flex; background: #eceef1; border-radius: var(--radius); padding: 3px; gap: 2px; }
.segmented button { border: none; background: transparent; font-family: inherit; font-size: 13px; font-weight: 600; color: var(--text-2); padding: 5px 12px; border-radius: 4px; cursor: pointer; white-space: nowrap; display: flex; align-items: center; gap: 6px; }
.segmented button.active { background: #fff; color: var(--text); box-shadow: var(--shadow-sm); }
.segmented button .n { font-size: 11px; color: var(--text-3); font-weight: 600; }
.segmented button.active .n { color: var(--accent); }
.sort { margin-left: auto; display: flex; align-items: center; gap: 6px; height: 36px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 11px; background: #fff; font-size: 13px; font-weight: 500; color: var(--text-2); cursor: pointer; white-space: nowrap; }
.sort svg { width: 15px; height: 15px; color: var(--text-3); }
/* task list */
.list { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow-sm); overflow: hidden; }
.task-row { display: flex; align-items: center; gap: 14px; padding: 14px 18px; border-top: 1px solid var(--border); cursor: pointer; text-decoration: none; color: inherit; }
.task-row:first-child { border-top: none; }
.task-row:hover { background: #fafbfc; }
.st-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
.st-dot.done { background: var(--green); } .st-dot.prog { background: var(--blue); } .st-dot.review { background: var(--amber); } .st-dot.todo { background: #c4c8cf; }
.task-main { flex: 1; min-width: 0; }
.task-title { font-size: 14px; font-weight: 600; color: var(--text); display: flex; align-items: center; gap: 8px; }
.task-title .tid { font-size: 12px; font-weight: 600; color: var(--text-3); }
.task-row.is-done .task-title { color: var(--text-2); }
.task-meta { display: flex; align-items: center; gap: 13px; margin-top: 3px; font-size: 12px; color: var(--text-3); }
.task-meta .mi { display: flex; align-items: center; gap: 5px; white-space: nowrap; }
.task-meta .mi svg { width: 13px; height: 13px; }
.task-meta .mi.overdue { color: var(--red); font-weight: 600; }
.task-right { display: flex; align-items: center; gap: 14px; flex-shrink: 0; }
.st-badge { font-size: 11.5px; font-weight: 600; padding: 3px 10px; border-radius: 20px; white-space: nowrap; min-width: 56px; text-align: center; }
.st-badge.done { color: var(--green); background: var(--green-weak); border: 1px solid #cde8d8; }
.st-badge.prog { color: var(--blue); background: var(--blue-weak); border: 1px solid var(--blue-border); }
.st-badge.review { color: var(--amber); background: var(--amber-weak); border: 1px solid var(--amber-border); }
.st-badge.todo { color: var(--text-2); background: #f1f2f4; border: 1px solid var(--border); }
.chev { color: var(--text-3); display: grid; place-items: center; }
.chev svg { width: 18px; height: 18px; }
</style>
</head>
<body>
<header class="topbar">
<div class="brand"><div class="logo">R</div>Relay</div>
<nav class="topnav">
<a href="내 업무.html">내 업무</a>
<a href="저장소 목록.html" class="active">저장소</a>
</nav>
<div class="topbar-right">
<button class="icon-btn" title="검색"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg></button>
<button class="icon-btn" title="알림"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg></button>
<div class="me"></div>
</div>
</header>
<div class="page" data-screen-label="저장소 상세">
<div class="crumb">
<a href="저장소 목록.html" class="lnk">저장소</a>
<span class="sep">/</span>
<b>3분기 신제품 런칭 캘페인</b>
</div>
<!-- 저장소 헤더 -->
<div class="repo-head">
<div class="repo-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg></div>
<div class="rh-main">
<div class="rh-title">
<span class="rh-name">3분기 신제품 런칭 캘페인</span>
<span class="vis private"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>비공개</span>
</div>
<div class="rh-slug">marketing-team / q3-launch-campaign</div>
<div class="rh-desc">3분기 신제품(라인 클렌저) 런칭 캠페인 소재 제작 및 검수</div>
<div class="rh-meta">
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>main</span>
<span class="mi avstack"><span class="avatar av-a"></span><span class="avatar av-b"></span><span class="avatar av-c"></span><span class="avatar av-d"></span><span class="avatar av-more">+1</span></span>
<span class="mi">2시간 전 업데이트</span>
</div>
<div class="prog-strip">
<span class="prog-txt">8 <span>/ 12 업무 완료</span> · 67%</span>
<div class="pbar"><i style="width:67%"></i></div>
<span class="prog-warn"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>지연 1건</span>
</div>
</div>
<div class="rh-actions">
<a class="btn primary" href="업무 작성.html"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5v14"/></svg>새 업무</a>
</div>
</div>
<!-- 서브탭 -->
<nav class="tabs">
<a href="#" class="active"><span class="tb-label">업무</span> <span class="tb-count">12</span></a>
<a href="저장소 멤버.html"><span class="tb-label">멤버</span> <span class="tb-count">5</span></a>
<a href="저장소 활동.html"><span class="tb-label">활동</span></a>
<a href="저장소 설정.html"><span class="tb-label">설정</span></a>
</nav>
<!-- 툴바 -->
<div class="toolbar">
<div class="search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>
<input type="text" placeholder="업무 검색…">
</div>
<div class="segmented">
<button class="active">전체 <span class="n">12</span></button>
<button>진행 중 <span class="n">2</span></button>
<button>대기 <span class="n">2</span></button>
<button>완료 <span class="n">8</span></button>
</div>
<div class="sort">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5h10M11 9h7M11 13h4M3 17l3 3 3-3M6 18V4"/></svg>
마감 임박순
</div>
</div>
<!-- 업무 목록 -->
<div class="list">
<a class="task-row" href="업무 상세.html">
<span class="st-dot prog"></span>
<span class="task-main">
<span class="task-title">법무 검토 요청 및 회신 반영 <span class="tid">#11</span></span>
<span class="task-meta">
<span class="mi overdue"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>2일 지남</span>
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>1/3</span>
</span>
</span>
<span class="task-right">
<span class="avstack"><span class="avatar av-c"></span></span>
<span class="st-badge prog">진행 중</span>
<span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></span>
</span>
</a>
<a class="task-row" href="업무 상세.html">
<span class="st-dot review"></span>
<span class="task-main">
<span class="task-title">메인 배너 이미지 2종 (가로/세로) 디자인 <span class="tid">#9</span></span>
<span class="task-meta">
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>D-3</span>
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>1/3</span>
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>2</span>
</span>
</span>
<span class="task-right">
<span class="avstack"><span class="avatar av-b"></span><span class="avatar av-d"></span></span>
<span class="st-badge review">승인 대기</span>
<span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></span>
</span>
</a>
<a class="task-row" href="업무 상세.html">
<span class="st-dot todo"></span>
<span class="task-main">
<span class="task-title">상세페이지 카피라이팅 <span class="tid">#8</span></span>
<span class="task-meta">
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>D-5</span>
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>0/3</span>
</span>
</span>
<span class="task-right">
<span class="avstack"><span class="avatar av-c"></span></span>
<span class="st-badge todo">대기</span>
<span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></span>
</span>
</a>
<a class="task-row" href="업무 상세.html">
<span class="st-dot todo"></span>
<span class="task-main">
<span class="task-title">SNS 콘텐츠 1차 시안 <span class="tid">#7</span></span>
<span class="task-meta">
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>D-8</span>
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>0/4</span>
</span>
</span>
<span class="task-right">
<span class="avstack"><span class="avatar av-e"></span></span>
<span class="st-badge todo">대기</span>
<span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></span>
</span>
</a>
<a class="task-row is-done" href="업무 상세.html">
<span class="st-dot done"></span>
<span class="task-main">
<span class="task-title">캠페인 핵심 메시지 시안 작성 <span class="tid">#6</span></span>
<span class="task-meta">
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>3/3</span>
<span class="mi">6월 9일 완료</span>
</span>
</span>
<span class="task-right">
<span class="avstack"><span class="avatar av-a"></span></span>
<span class="st-badge done">완료</span>
<span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></span>
</span>
</a>
<a class="task-row is-done" href="업무 상세.html">
<span class="st-dot done"></span>
<span class="task-main">
<span class="task-title">레퍼런스 리서치 및 무드보드 정리 <span class="tid">#5</span></span>
<span class="task-meta"><span class="mi">6월 6일 완료</span></span>
</span>
<span class="task-right">
<span class="avstack"><span class="avatar av-a"></span></span>
<span class="st-badge done">완료</span>
<span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></span>
</span>
</a>
<a class="task-row is-done" href="업무 상세.html">
<span class="st-dot done"></span>
<span class="task-main">
<span class="task-title">퍼포먼스 광고 소재 세팅 <span class="tid">#4</span></span>
<span class="task-meta">
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>2/2</span>
<span class="mi">6월 5일 완료</span>
</span>
</span>
<span class="task-right">
<span class="avstack"><span class="avatar av-b"></span></span>
<span class="st-badge done">완료</span>
<span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></span>
</span>
</a>
<a class="task-row is-done" href="업무 상세.html">
<span class="st-dot done"></span>
<span class="task-main">
<span class="task-title">타깃 오디언스 정의 및 페르소나 정리 <span class="tid">#3</span></span>
<span class="task-meta"><span class="mi">6월 4일 완료</span></span>
</span>
<span class="task-right">
<span class="avstack"><span class="avatar av-b"></span></span>
<span class="st-badge done">완료</span>
<span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></span>
</span>
</a>
<a class="task-row is-done" href="업무 상세.html">
<span class="st-dot done"></span>
<span class="task-main">
<span class="task-title">킥오프 미팅 및 일정 수립 <span class="tid">#1</span></span>
<span class="task-meta"><span class="mi">6월 2일 완료</span></span>
</span>
<span class="task-right">
<span class="avstack"><span class="avatar av-f"></span></span>
<span class="st-badge done">완료</span>
<span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></span>
</span>
</a>
</div>
</div>
</body>
</html>
+289
View File
@@ -0,0 +1,289 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>새 저장소 만들기 — Relay</title>
<link rel="preconnect" href="https://cdn.jsdelivr.net">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f4f5f7;
--panel: #ffffff;
--border: #e6e8ec;
--border-strong: #d7dae0;
--text: #16181d;
--text-2: #5b626e;
--text-3: #9298a3;
--accent: #4f46e5;
--accent-hover: #4338ca;
--accent-weak: #eef0fe;
--accent-border: #c7ccfb;
--green: #1f9d57;
--green-weak: #e8f6ee;
--amber: #b7791f;
--radius: 6px;
--radius-sm: 4px;
--shadow-sm: 0 1px 2px rgba(20,24,33,.05);
--shadow-md: 0 4px 16px rgba(20,24,33,.10), 0 1px 3px rgba(20,24,33,.06);
}
html, body {
background: var(--bg); color: var(--text);
font-family: "Pretendard", -apple-system, system-ui, sans-serif;
font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased;
}
body { min-height: 100vh; }
/* ---------- Top app bar ---------- */
.topbar {
height: 52px; background: var(--panel); border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 28px; padding: 0 20px;
position: sticky; top: 0; z-index: 50;
}
.brand { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 15px; letter-spacing: -.2px; }
.brand .logo {
width: 26px; height: 26px; border-radius: 7px;
background: linear-gradient(135deg, #5b54f0, #4338ca);
display: grid; place-items: center; color: #fff; font-size: 15px; font-weight: 800;
box-shadow: 0 2px 6px rgba(79,70,229,.35);
}
.topnav { display: flex; align-items: center; gap: 2px; }
.topnav a {
color: var(--text-2); text-decoration: none; font-size: 13.5px; font-weight: 500;
padding: 7px 12px; border-radius: var(--radius-sm); line-height: 1; white-space: nowrap;
}
.topnav a:hover { background: #f1f2f4; color: var(--text); }
.topnav a.active { color: var(--accent); background: var(--accent-weak); font-weight: 600; }
.crumb .lnk { color: var(--text-2); font-weight: 600; text-decoration: none; }
.crumb .lnk:hover { color: var(--accent); }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.icon-btn { width: 32px; height: 32px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-2); display: grid; place-items: center; cursor: pointer; }
.icon-btn:hover { background: #f1f2f4; color: var(--text); }
.me { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-size: 11.5px; font-weight: 700; color: #fff; background: #0ea5a3; margin-left: 4px; }
/* ---------- Page ---------- */
.page { max-width: 760px; margin: 0 auto; padding: 0 24px 70px; }
.crumb { display: flex; align-items: center; gap: 7px; color: var(--text-3); font-size: 12.5px; padding: 18px 0 0; white-space: nowrap; }
.crumb b { color: var(--text-2); font-weight: 600; }
.crumb .sep { opacity: .5; }
.pagehead { display: flex; align-items: center; gap: 12px; padding: 9px 0 6px; }
.pagehead h1 { font-size: 22px; font-weight: 700; letter-spacing: -.4px; white-space: nowrap; flex-shrink: 0; }
.gitea-badge {
display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600;
color: #1b7a3d; background: var(--green-weak); border: 1px solid #cde8d8;
padding: 4px 10px; border-radius: 20px; line-height: 1; white-space: nowrap;
}
.gitea-badge svg { width: 13px; height: 13px; }
.lede { color: var(--text-2); font-size: 13.5px; margin-bottom: 20px; }
/* ---------- Card / form ---------- */
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow-sm); }
.fblock { padding: 22px 24px; border-top: 1px solid var(--border); }
.fblock:first-child { border-top: none; }
.flabel { font-size: 13px; font-weight: 600; color: var(--text); margin-bottom: 4px; display: flex; align-items: center; gap: 7px; }
.flabel .req { color: var(--accent); font-weight: 700; }
.fhint { font-size: 12.5px; color: var(--text-3); margin-bottom: 11px; line-height: 1.5; }
.fhint.below { margin-top: 9px; margin-bottom: 0; }
.tinput { width: 100%; height: 40px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 12px; font-family: inherit; font-size: 14px; color: var(--text); background: #fff; }
.tinput:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
.tinput::placeholder { color: var(--text-3); }
textarea.tarea { min-height: 96px; padding: 10px 12px; line-height: 1.6; resize: vertical; height: auto; }
/* owner / repo name */
.repo-id { display: flex; }
.owner-fixed {
display: flex; align-items: center; gap: 8px; height: 40px; padding: 0 13px;
background: #f1f2f4; border: 1px solid var(--border-strong); border-right: none;
border-radius: var(--radius) 0 0 var(--radius); color: var(--text-2);
font-weight: 600; font-size: 14px; cursor: not-allowed; white-space: nowrap; user-select: none;
}
.owner-fixed .lock { color: var(--text-3); display: grid; place-items: center; }
.owner-avatar { width: 20px; height: 20px; border-radius: 5px; background: #0ea5a3; color: #fff; display: grid; place-items: center; font-size: 10.5px; font-weight: 700; }
.name-wrap { flex: 1; height: 40px; display: flex; align-items: center; border: 1px solid var(--border-strong); border-radius: 0 var(--radius) var(--radius) 0; background: #fff; padding: 0 12px; min-width: 0; }
.name-wrap:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); position: relative; z-index: 1; }
.name-wrap .slash { color: var(--text-3); margin-right: 7px; font-weight: 500; font-size: 15px; }
.name-wrap input { flex: 1; min-width: 0; border: none; outline: none; height: 100%; font-family: inherit; font-size: 14px; background: transparent; }
.name-wrap input::placeholder { color: var(--text-3); }
.avail { display: flex; align-items: center; gap: 6px; font-size: 12.5px; font-weight: 600; color: var(--green); margin-top: 9px; }
.avail svg { width: 14px; height: 14px; }
.fixed-pill { font-size: 11px; font-weight: 600; color: var(--text-2); background: #e9ebef; border: 1px solid var(--border-strong); padding: 1px 7px; border-radius: 20px; line-height: 1.5; white-space: nowrap; }
/* visibility radio cards */
.radio-group { display: flex; flex-direction: column; gap: 10px; }
.radio-card { display: flex; align-items: flex-start; gap: 13px; padding: 13px 14px; border: 1px solid var(--border-strong); border-radius: 8px; cursor: pointer; background: #fff; transition: border-color .12s, background .12s; }
.radio-card:hover { border-color: var(--accent-border); }
.radio-card.sel { border-color: var(--accent); background: var(--accent-weak); }
.rc-ico { width: 34px; height: 34px; border-radius: 8px; background: #f1f2f4; color: var(--text-2); display: grid; place-items: center; flex-shrink: 0; }
.rc-ico svg { width: 18px; height: 18px; }
.radio-card.sel .rc-ico { background: #fff; color: var(--accent); }
.rc-body { flex: 1; }
.rc-title { font-size: 14px; font-weight: 600; color: var(--text); }
.rc-desc { font-size: 12.5px; color: var(--text-2); margin-top: 2px; line-height: 1.45; }
.rc-radio { width: 18px; height: 18px; border-radius: 50%; border: 1.5px solid var(--border-strong); flex-shrink: 0; margin-top: 8px; display: grid; place-items: center; background: #fff; }
.radio-card.sel .rc-radio { border-color: var(--accent); }
.radio-card.sel .rc-radio::after { content: ""; width: 9px; height: 9px; border-radius: 50%; background: var(--accent); }
/* select */
.select-wrap { position: relative; }
.select-wrap select {
appearance: none; -webkit-appearance: none; width: 100%; height: 40px;
border: 1px solid var(--border-strong); border-radius: var(--radius);
padding: 0 38px 0 12px; font-family: inherit; font-size: 14px; color: var(--text); background: #fff; cursor: pointer;
}
.select-wrap select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
.select-wrap .caret { position: absolute; right: 12px; top: 50%; transform: translateY(-50%); color: var(--text-3); pointer-events: none; display: grid; place-items: center; }
.select-wrap .caret svg { width: 16px; height: 16px; }
/* input with icon */
.input-ico { display: flex; align-items: center; border: 1px solid var(--border-strong); border-radius: var(--radius); background: #fff; padding: 0 12px; gap: 9px; }
.input-ico:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
.input-ico .ico { color: var(--text-3); display: grid; place-items: center; }
.input-ico .ico svg { width: 16px; height: 16px; }
.input-ico input { flex: 1; border: none; outline: none; height: 40px; font-family: inherit; font-size: 14px; background: transparent; color: var(--text); }
/* footer */
.form-footer { display: flex; align-items: center; gap: 9px; padding: 16px 24px; border-top: 1px solid var(--border); background: #fafbfc; border-radius: 0 0 10px 10px; }
.form-footer .note { color: var(--text-3); font-size: 12.5px; margin-right: auto; display: flex; align-items: center; gap: 7px; white-space: nowrap; }
.form-footer .note .step { display: inline-grid; place-items: center; width: 16px; height: 16px; border-radius: 50%; background: var(--accent-weak); color: var(--accent); font-size: 10px; font-weight: 700; }
.btn { height: 36px; padding: 0 16px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; border: 1px solid var(--border-strong); background: var(--panel); color: var(--text-2); cursor: pointer; display: inline-flex; align-items: center; gap: 6px; line-height: 1; text-decoration: none; }
.btn:hover { background: #f7f8fa; color: var(--text); }
.btn.ghost { border-color: transparent; background: transparent; }
.btn.ghost:hover { background: #eceef1; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
.btn.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
</style>
</head>
<body>
<header class="topbar">
<div class="brand"><div class="logo">R</div>Relay</div>
<nav class="topnav">
<a href="내 업무.html">내 업무</a>
<a href="저장소 목록.html" class="active">저장소</a>
</nav>
<div class="topbar-right">
<button class="icon-btn" title="검색">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>
</button>
<button class="icon-btn" title="알림">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
</button>
<div class="me"></div>
</div>
</header>
<div class="page">
<div class="crumb"><a href="저장소 목록.html" class="lnk">저장소</a> <span class="sep">/</span> 새 저장소</div>
<div class="pagehead">
<h1>새 저장소 만들기</h1>
<span class="gitea-badge">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 17H7A5 5 0 0 1 7 7h2"/><path d="M15 7h2a5 5 0 0 1 0 10h-2"/><path d="M8 12h8"/></svg>
Gitea 연동됨
</span>
</div>
<p class="lede">Gitea 저장소를 연동하여 새 프로젝트를 생성합니다. 생성 후 이 저장소에 업무를 작성하고 담당자에게 전달할 수 있습니다.</p>
<div class="card">
<!-- 소유자 / 저장소 이름 -->
<div class="fblock">
<div class="flabel"><span class="req">*</span> 소유자 / 저장소 이름 <span class="fixed-pill">소유자 고정</span></div>
<div class="fhint">소유자는 현재 조직으로 고정되어 변경할 수 없습니다.</div>
<div class="repo-id">
<div class="owner-fixed" title="소유자는 변경할 수 없습니다">
<span class="owner-avatar">M</span>
marketing-team
<span class="lock"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>
</div>
<div class="name-wrap">
<span class="slash">/</span>
<input type="text" value="q3-launch-campaign" placeholder="repository-name" spellcheck="false">
</div>
</div>
<div class="avail">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
사용 가능한 이름입니다
</div>
<div class="fhint below">영문 소문자·숫자와 하이픈(-)·밑줄(_)만 사용할 수 있습니다. 한글·공백은 입력할 수 없습니다.</div>
</div>
<!-- 표시 제목 -->
<div class="fblock">
<div class="flabel"><span class="req">*</span> 표시 제목</div>
<div class="fhint">Relay 목록과 화면에 표시되는 이름입니다. 한글로 입력하면 저장소를 알아보기 쉽습니다. 위 Gitea 저장소 이름(영문)과는 별개이며 언제든 바꿀 수 있습니다.</div>
<input type="text" class="tinput" value="3분기 신제품 런칭 캠페인" placeholder="예: 3분기 신제품 런칭 캠페인" maxlength="40">
</div>
<!-- 공개 범위 -->
<div class="fblock">
<div class="flabel"><span class="req">*</span> 공개 범위</div>
<div class="fhint">저장소와 그 안의 업무를 누가 볼 수 있는지 결정합니다.</div>
<div class="radio-group">
<label class="radio-card sel">
<span class="rc-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>
<span class="rc-body">
<span class="rc-title">비공개</span>
<span class="rc-desc">저장소에 초대된 멤버만 접근하고 업무를 확인할 수 있습니다.</span>
</span>
<span class="rc-radio"></span>
</label>
<label class="radio-card">
<span class="rc-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15 15 0 0 1 0 20M12 2a15 15 0 0 0 0 20"/></svg></span>
<span class="rc-body">
<span class="rc-title">공개</span>
<span class="rc-desc">조직 내 모든 구성원이 저장소를 보고 업무 현황을 열람할 수 있습니다.</span>
</span>
<span class="rc-radio"></span>
</label>
</div>
</div>
<!-- 프로젝트 설명 -->
<div class="fblock">
<div class="flabel">프로젝트 설명</div>
<div class="fhint">저장소 목록과 상단에 표시됩니다. (선택)</div>
<textarea class="tinput tarea" placeholder="이 프로젝트의 목적과 범위를 간단히 적어주세요.">3분기 신제품(라인 클렌저) 런칭 관련 캠페인 소재 제작 및 검수 업무를 관리하는 저장소입니다.</textarea>
</div>
<!-- 템플릿 -->
<div class="fblock">
<div class="flabel">템플릿</div>
<div class="fhint">선택한 템플릿의 폴더 구조와 기본 업무가 새 저장소에 복제됩니다.</div>
<div class="select-wrap">
<select>
<option>없음 (빈 저장소)</option>
<option>기본 업무 템플릿</option>
<option>디자인 프로젝트 템플릿</option>
<option>개발 프로젝트 템플릿</option>
</select>
<span class="caret"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg></span>
</div>
</div>
<!-- 메인 브랜치 -->
<div class="fblock">
<div class="flabel"><span class="req">*</span> 메인 브랜치</div>
<div class="fhint">저장소의 기본 브랜치 이름입니다.</div>
<div class="input-ico" style="max-width:280px">
<span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg></span>
<input type="text" value="main" spellcheck="false">
</div>
</div>
<!-- footer -->
<div class="form-footer">
<span class="note"><span class="step">1</span> 저장소 생성 → 업무 생성 → 전달</span>
<a class="btn ghost" href="저장소 목록.html">취소</a>
<a class="btn primary" href="저장소 상세.html">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5v14"/></svg>
저장소 생성
</a>
</div>
</div>
</div>
</body>
</html>
+275
View File
@@ -0,0 +1,275 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>q3-launch-campaign · 설정 — Relay</title>
<link rel="preconnect" href="https://cdn.jsdelivr.net">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f4f5f7; --panel: #ffffff; --border: #e6e8ec; --border-strong: #d7dae0;
--text: #16181d; --text-2: #5b626e; --text-3: #9298a3;
--accent: #4f46e5; --accent-hover: #4338ca; --accent-weak: #eef0fe; --accent-border: #c7ccfb;
--green: #1f9d57; --green-weak: #e8f6ee; --red: #d6455d; --red-weak: #fdeef0; --red-border: #f1c0c8;
--radius: 6px; --radius-sm: 4px; --shadow-sm: 0 1px 2px rgba(20,24,33,.05);
}
html, body { background: var(--bg); color: var(--text); font-family: "Pretendard", -apple-system, system-ui, sans-serif; font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
body { min-height: 100vh; }
/* topbar */
.topbar { height: 52px; background: var(--panel); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 28px; padding: 0 20px; position: sticky; top: 0; z-index: 50; }
.brand { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 15px; letter-spacing: -.2px; }
.brand .logo { width: 26px; height: 26px; border-radius: 7px; background: linear-gradient(135deg, #5b54f0, #4338ca); display: grid; place-items: center; color: #fff; font-size: 15px; font-weight: 800; box-shadow: 0 2px 6px rgba(79,70,229,.35); }
.topnav { display: flex; align-items: center; gap: 2px; }
.topnav a { color: var(--text-2); text-decoration: none; font-size: 13.5px; font-weight: 500; padding: 7px 12px; border-radius: var(--radius-sm); line-height: 1; white-space: nowrap; }
.topnav a:hover { background: #f1f2f4; color: var(--text); }
.topnav a.active { color: var(--accent); background: var(--accent-weak); font-weight: 600; }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.icon-btn { width: 32px; height: 32px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-2); display: grid; place-items: center; cursor: pointer; }
.icon-btn:hover { background: #f1f2f4; color: var(--text); }
.me { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-size: 11.5px; font-weight: 700; color: #fff; background: #0ea5a3; margin-left: 4px; }
/* page */
.page { max-width: 1080px; margin: 0 auto; padding: 0 24px 70px; }
.crumb { display: flex; align-items: center; gap: 7px; color: var(--text-3); font-size: 12.5px; padding: 18px 0 12px; white-space: nowrap; }
.crumb .lnk { color: var(--text-2); font-weight: 600; text-decoration: none; }
.crumb .lnk:hover { color: var(--accent); }
.crumb .sep { opacity: .5; }
.crumb b { color: var(--text-2); font-weight: 600; }
.btn { height: 36px; padding: 0 15px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; border: 1px solid var(--border-strong); background: var(--panel); color: var(--text-2); cursor: pointer; display: inline-flex; align-items: center; gap: 6px; line-height: 1; text-decoration: none; white-space: nowrap; }
.btn:hover { background: #f7f8fa; color: var(--text); }
.btn svg { width: 15px; height: 15px; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
.btn.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
/* repo header */
.repo-head { display: flex; align-items: flex-start; gap: 16px; background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 20px 22px; box-shadow: var(--shadow-sm); }
.repo-ico { width: 46px; height: 46px; border-radius: 10px; background: var(--accent-weak); color: var(--accent); display: grid; place-items: center; flex-shrink: 0; }
.repo-ico svg { width: 24px; height: 24px; }
.rh-main { flex: 1; min-width: 0; }
.rh-title { display: flex; align-items: center; gap: 10px; }
.rh-name { font-size: 18px; font-weight: 700; letter-spacing: -.3px; white-space: nowrap; }
.rh-slug { font-size: 12.5px; color: var(--text-3); margin-top: 5px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.vis { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 20px; display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; line-height: 1.5; }
.vis svg { width: 11px; height: 11px; }
.vis.private { color: var(--text-2); background: #f1f2f4; border: 1px solid var(--border); }
.rh-desc { font-size: 13.5px; color: var(--text-2); margin-top: 6px; }
.rh-meta { display: flex; align-items: center; gap: 15px; margin-top: 12px; font-size: 12px; color: var(--text-3); }
.rh-meta .mi { display: flex; align-items: center; gap: 5px; white-space: nowrap; }
.rh-meta .mi svg { width: 13px; height: 13px; }
.avatar { width: 21px; height: 21px; border-radius: 50%; display: grid; place-items: center; font-size: 10px; font-weight: 700; color: #fff; flex-shrink: 0; }
.avstack { display: flex; align-items: center; }
.avstack .avatar { margin-left: -7px; box-shadow: 0 0 0 2px #fff; }
.avstack .avatar:first-child { margin-left: 0; }
.av-a { background: #e0518d; } .av-b { background: #3d8bf2; } .av-c { background: #16a37b; }
.av-d { background: #b3631f; } .av-f { background: #d0982a; }
.rh-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
/* tabs */
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); margin: 20px 0 22px; }
.tabs a { padding: 10px 14px; font-size: 13.5px; font-weight: 600; color: var(--text-2); text-decoration: none; border-bottom: 2px solid transparent; margin-bottom: -1px; display: flex; align-items: center; gap: 6px; white-space: nowrap; }
.tabs a:hover { color: var(--text); }
.tabs a.active { color: var(--accent); border-bottom-color: var(--accent); }
.tabs a .tb-label { line-height: 1; }
.tabs a .tb-count { font-size: 11px; font-weight: 600; color: var(--text-2); background: #f1f2f4; border-radius: 20px; padding: 0 7px; height: 17px; min-width: 17px; display: inline-flex; align-items: center; justify-content: center; line-height: 1; }
/* settings layout */
.settings { max-width: 720px; }
.sec-title { font-size: 15px; font-weight: 700; letter-spacing: -.2px; margin: 0 0 12px; }
.sec-title.danger { color: var(--red); }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow-sm); margin-bottom: 30px; }
.fblock { padding: 20px 22px; border-top: 1px solid var(--border); }
.fblock:first-child { border-top: none; }
.flabel { font-size: 13px; font-weight: 600; color: var(--text); margin-bottom: 5px; display: flex; align-items: center; gap: 7px; }
.fhint { font-size: 12.5px; color: var(--text-3); margin-bottom: 11px; line-height: 1.5; }
.fixed-pill { font-size: 11px; font-weight: 600; color: var(--text-2); background: #e9ebef; border: 1px solid var(--border-strong); padding: 1px 7px; border-radius: 20px; line-height: 1.5; white-space: nowrap; }
.tinput { width: 100%; height: 40px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 12px; font-family: inherit; font-size: 14px; color: var(--text); background: #fff; }
.tinput:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
textarea.tarea { min-height: 84px; padding: 10px 12px; line-height: 1.6; resize: vertical; height: auto; }
.readonly { display: flex; align-items: center; gap: 9px; height: 40px; padding: 0 13px; background: #f5f6f8; border: 1px solid var(--border); border-radius: var(--radius); color: var(--text-2); font-size: 14px; font-weight: 500; cursor: not-allowed; }
.readonly .owner-avatar { width: 20px; height: 20px; border-radius: 5px; background: #0ea5a3; color: #fff; display: grid; place-items: center; font-size: 10.5px; font-weight: 700; }
.readonly .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--text); font-weight: 600; }
.readonly .lock { margin-left: auto; color: var(--text-3); display: grid; place-items: center; }
.readonly .lock svg { width: 14px; height: 14px; }
/* visibility radio cards */
.radio-group { display: flex; flex-direction: column; gap: 10px; }
.radio-card { display: flex; align-items: flex-start; gap: 13px; padding: 13px 14px; border: 1px solid var(--border-strong); border-radius: 8px; cursor: pointer; background: #fff; }
.radio-card:hover { border-color: var(--accent-border); }
.radio-card.sel { border-color: var(--accent); background: var(--accent-weak); }
.rc-ico { width: 32px; height: 32px; border-radius: 8px; background: #f1f2f4; color: var(--text-2); display: grid; place-items: center; flex-shrink: 0; }
.rc-ico svg { width: 17px; height: 17px; }
.radio-card.sel .rc-ico { background: #fff; color: var(--accent); }
.rc-body { flex: 1; }
.rc-title { font-size: 14px; font-weight: 600; color: var(--text); }
.rc-desc { font-size: 12.5px; color: var(--text-2); margin-top: 2px; line-height: 1.45; }
.rc-radio { width: 18px; height: 18px; border-radius: 50%; border: 1.5px solid var(--border-strong); flex-shrink: 0; margin-top: 7px; display: grid; place-items: center; background: #fff; }
.radio-card.sel .rc-radio { border-color: var(--accent); }
.radio-card.sel .rc-radio::after { content: ""; width: 9px; height: 9px; border-radius: 50%; background: var(--accent); }
.input-ico { display: flex; align-items: center; border: 1px solid var(--border-strong); border-radius: var(--radius); background: #fff; padding: 0 12px; gap: 9px; max-width: 300px; }
.input-ico:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
.input-ico .ico { color: var(--text-3); display: grid; place-items: center; }
.input-ico .ico svg { width: 16px; height: 16px; }
.input-ico input { flex: 1; border: none; outline: none; height: 40px; font-family: inherit; font-size: 14px; background: transparent; color: var(--text); }
.card-foot { display: flex; justify-content: flex-end; padding: 14px 22px; border-top: 1px solid var(--border); background: #fafbfc; border-radius: 0 0 10px 10px; }
/* danger zone */
.danger-card { background: #fff; border: 1px solid var(--red-border); border-radius: 10px; box-shadow: var(--shadow-sm); overflow: hidden; }
.danger-row { display: flex; align-items: center; gap: 16px; padding: 16px 20px; border-top: 1px solid #f6dde2; }
.danger-row:first-child { border-top: none; }
.dr-main { flex: 1; min-width: 0; }
.dr-title { font-size: 14px; font-weight: 600; color: var(--text); }
.dr-desc { font-size: 12.5px; color: var(--text-2); margin-top: 3px; line-height: 1.5; }
.btn-danger { height: 34px; padding: 0 14px; border-radius: var(--radius); font-size: 13px; font-weight: 600; border: 1px solid var(--red-border); background: #fff; color: var(--red); cursor: pointer; white-space: nowrap; flex-shrink: 0; }
.btn-danger:hover { background: var(--red-weak); }
.btn-danger.solid { background: var(--red); border-color: var(--red); color: #fff; }
.btn-danger.solid:hover { background: #c23a51; }
.admin-note { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--text-3); margin-bottom: 18px; }
.admin-note svg { width: 14px; height: 14px; flex-shrink: 0; }
</style>
</head>
<body>
<header class="topbar">
<div class="brand"><div class="logo">R</div>Relay</div>
<nav class="topnav">
<a href="내 업무.html">내 업무</a>
<a href="저장소 목록.html" class="active">저장소</a>
</nav>
<div class="topbar-right">
<button class="icon-btn" title="검색"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg></button>
<button class="icon-btn" title="알림"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg></button>
<div class="me"></div>
</div>
</header>
<div class="page" data-screen-label="저장소 설정">
<div class="crumb">
<a href="저장소 목록.html" class="lnk">저장소</a>
<span class="sep">/</span>
<a href="저장소 상세.html" class="lnk">3분기 신제품 런칭 캠페인</a>
<span class="sep">/</span>
<b>설정</b>
</div>
<!-- 저장소 헤더 -->
<div class="repo-head">
<div class="repo-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg></div>
<div class="rh-main">
<div class="rh-title">
<span class="rh-name">3분기 신제품 런칭 캠페인</span>
<span class="vis private"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>비공개</span>
</div>
<div class="rh-slug">marketing-team / q3-launch-campaign</div>
<div class="rh-desc">3분기 신제품(라인 클렌저) 런칭 캠페인 소재 제작 및 검수</div>
<div class="rh-meta">
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>main</span>
<span class="mi avstack"><span class="avatar av-f"></span><span class="avatar av-a"></span><span class="avatar av-b"></span><span class="avatar av-c"></span><span class="avatar av-d"></span></span>
<span class="mi">멤버 5명</span>
</div>
</div>
<div class="rh-actions">
<a class="btn primary" href="업무 작성.html"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5v14"/></svg>새 업무</a>
</div>
</div>
<!-- 서브탭 -->
<nav class="tabs">
<a href="저장소 상세.html"><span class="tb-label">업무</span> <span class="tb-count">12</span></a>
<a href="저장소 멤버.html"><span class="tb-label">멤버</span> <span class="tb-count">5</span></a>
<a href="저장소 활동.html"><span class="tb-label">활동</span></a>
<a href="#" class="active"><span class="tb-label">설정</span></a>
</nav>
<div class="settings">
<div class="admin-note">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
저장소 설정은 <b style="color:var(--text-2);font-weight:600;white-space:nowrap">관리자</b>만 변경할 수 있습니다.
</div>
<!-- ===== 일반 ===== -->
<h2 class="sec-title">일반</h2>
<div class="card">
<div class="fblock">
<div class="flabel">표시 제목</div>
<div class="fhint">Relay 목록과 화면에 표시되는 이름입니다. (한글 가능)</div>
<input class="tinput" type="text" value="3분기 신제품 런칭 캠페인" maxlength="40">
</div>
<div class="fblock">
<div class="flabel">소유자 / Gitea 저장소 이름 <span class="fixed-pill">소유자 고정</span></div>
<div class="fhint">Gitea 저장소 이름(영문)은 주소에 사용됩니다. 변경은 아래 위험 구역에서 할 수 있습니다.</div>
<div class="readonly">
<span class="owner-avatar">M</span> marketing-team <span style="color:var(--text-3)">/</span> <span class="mono">q3-launch-campaign</span>
<span class="lock"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>
</div>
</div>
<div class="fblock">
<div class="flabel">프로젝트 설명</div>
<textarea class="tinput tarea">3분기 신제품(라인 클렌저) 런칭 관련 캠페인 소재 제작 및 검수 업무를 관리하는 저장소입니다.</textarea>
</div>
<div class="fblock">
<div class="flabel">공개 범위</div>
<div class="fhint">저장소와 그 안의 업무를 누가 볼 수 있는지 결정합니다.</div>
<div class="radio-group">
<label class="radio-card sel">
<span class="rc-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>
<span class="rc-body"><span class="rc-title">비공개</span><span class="rc-desc">저장소에 초대된 멤버만 접근하고 업무를 확인할 수 있습니다.</span></span>
<span class="rc-radio"></span>
</label>
<label class="radio-card">
<span class="rc-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15 15 0 0 1 0 20M12 2a15 15 0 0 0 0 20"/></svg></span>
<span class="rc-body"><span class="rc-title">공개</span><span class="rc-desc">조직 내 모든 구성원이 저장소를 보고 업무 현황을 열람할 수 있습니다.</span></span>
<span class="rc-radio"></span>
</label>
</div>
</div>
<div class="fblock">
<div class="flabel">메인 브랜치</div>
<div class="fhint">저장소의 기본 브랜치 이름입니다.</div>
<div class="input-ico">
<span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg></span>
<input type="text" value="main" spellcheck="false">
</div>
</div>
<div class="card-foot">
<button class="btn primary" type="button">변경 저장</button>
</div>
</div>
<!-- ===== 위험 구역 ===== -->
<h2 class="sec-title danger">위험 구역</h2>
<div class="danger-card">
<div class="danger-row">
<div class="dr-main">
<div class="dr-title">Gitea 저장소 이름 변경</div>
<div class="dr-desc">영문 저장소 이름과 Gitea 주소가 함께 바뀝니다. 기존 링크가 동작하지 않을 수 있습니다.</div>
</div>
<button class="btn-danger" type="button">이름 변경</button>
</div>
<div class="danger-row">
<div class="dr-main">
<div class="dr-title">저장소 삭제</div>
<div class="dr-desc">저장소와 그 안의 모든 업무·활동 기록이 영구 삭제됩니다. 되돌릴 수 없습니다.</div>
</div>
<button class="btn-danger solid" type="button">저장소 삭제</button>
</div>
</div>
</div>
</div>
</body>
</html>
+286
View File
@@ -0,0 +1,286 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>q3-launch-campaign · 활동 — Relay</title>
<link rel="preconnect" href="https://cdn.jsdelivr.net">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f4f5f7; --panel: #ffffff; --border: #e6e8ec; --border-strong: #d7dae0;
--text: #16181d; --text-2: #5b626e; --text-3: #9298a3;
--accent: #4f46e5; --accent-hover: #4338ca; --accent-weak: #eef0fe; --accent-border: #c7ccfb;
--green: #1f9d57; --green-weak: #e8f6ee; --blue: #1d4ed8; --blue-weak: #e8efff; --blue-border: #c9dbff;
--red: #d6455d; --amber: #b7791f; --amber-weak: #fdf4e3;
--radius: 6px; --radius-sm: 4px; --shadow-sm: 0 1px 2px rgba(20,24,33,.05);
}
html, body { background: var(--bg); color: var(--text); font-family: "Pretendard", -apple-system, system-ui, sans-serif; font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
body { min-height: 100vh; }
/* topbar */
.topbar { height: 52px; background: var(--panel); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 28px; padding: 0 20px; position: sticky; top: 0; z-index: 50; }
.brand { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 15px; letter-spacing: -.2px; }
.brand .logo { width: 26px; height: 26px; border-radius: 7px; background: linear-gradient(135deg, #5b54f0, #4338ca); display: grid; place-items: center; color: #fff; font-size: 15px; font-weight: 800; box-shadow: 0 2px 6px rgba(79,70,229,.35); }
.topnav { display: flex; align-items: center; gap: 2px; }
.topnav a { color: var(--text-2); text-decoration: none; font-size: 13.5px; font-weight: 500; padding: 7px 12px; border-radius: var(--radius-sm); line-height: 1; white-space: nowrap; }
.topnav a:hover { background: #f1f2f4; color: var(--text); }
.topnav a.active { color: var(--accent); background: var(--accent-weak); font-weight: 600; }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.icon-btn { width: 32px; height: 32px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-2); display: grid; place-items: center; cursor: pointer; }
.icon-btn:hover { background: #f1f2f4; color: var(--text); }
.me { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-size: 11.5px; font-weight: 700; color: #fff; background: #0ea5a3; margin-left: 4px; }
/* page */
.page { max-width: 1080px; margin: 0 auto; padding: 0 24px 70px; }
.crumb { display: flex; align-items: center; gap: 7px; color: var(--text-3); font-size: 12.5px; padding: 18px 0 12px; white-space: nowrap; }
.crumb .lnk { color: var(--text-2); font-weight: 600; text-decoration: none; }
.crumb .lnk:hover { color: var(--accent); }
.crumb .sep { opacity: .5; }
.crumb b { color: var(--text-2); font-weight: 600; }
.btn { height: 36px; padding: 0 15px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; border: 1px solid var(--border-strong); background: var(--panel); color: var(--text-2); cursor: pointer; display: inline-flex; align-items: center; gap: 6px; line-height: 1; text-decoration: none; white-space: nowrap; }
.btn:hover { background: #f7f8fa; color: var(--text); }
.btn svg { width: 15px; height: 15px; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
.btn.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
/* repo header */
.repo-head { display: flex; align-items: flex-start; gap: 16px; background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 20px 22px; box-shadow: var(--shadow-sm); }
.repo-ico { width: 46px; height: 46px; border-radius: 10px; background: var(--accent-weak); color: var(--accent); display: grid; place-items: center; flex-shrink: 0; }
.repo-ico svg { width: 24px; height: 24px; }
.rh-main { flex: 1; min-width: 0; }
.rh-title { display: flex; align-items: center; gap: 10px; }
.rh-name { font-size: 18px; font-weight: 700; letter-spacing: -.3px; white-space: nowrap; }
.rh-slug { font-size: 12.5px; color: var(--text-3); margin-top: 5px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.vis { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 20px; display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; line-height: 1.5; }
.vis svg { width: 11px; height: 11px; }
.vis.private { color: var(--text-2); background: #f1f2f4; border: 1px solid var(--border); }
.rh-desc { font-size: 13.5px; color: var(--text-2); margin-top: 6px; }
.rh-meta { display: flex; align-items: center; gap: 15px; margin-top: 12px; font-size: 12px; color: var(--text-3); }
.rh-meta .mi { display: flex; align-items: center; gap: 5px; white-space: nowrap; }
.rh-meta .mi svg { width: 13px; height: 13px; }
.avatar { width: 21px; height: 21px; border-radius: 50%; display: grid; place-items: center; font-size: 10px; font-weight: 700; color: #fff; flex-shrink: 0; }
.avstack { display: flex; align-items: center; }
.avstack .avatar { margin-left: -7px; box-shadow: 0 0 0 2px #fff; }
.avstack .avatar:first-child { margin-left: 0; }
.av-a { background: #e0518d; } .av-b { background: #3d8bf2; } .av-c { background: #16a37b; }
.av-d { background: #b3631f; } .av-e { background: #7c5cf0; } .av-f { background: #d0982a; }
.av-more { background: #eceef1; color: var(--text-2); box-shadow: 0 0 0 2px #fff; }
.rh-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
/* tabs */
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); margin: 20px 0 18px; }
.tabs a { padding: 10px 14px; font-size: 13.5px; font-weight: 600; color: var(--text-2); text-decoration: none; border-bottom: 2px solid transparent; margin-bottom: -1px; display: flex; align-items: center; gap: 6px; white-space: nowrap; }
.tabs a:hover { color: var(--text); }
.tabs a.active { color: var(--accent); border-bottom-color: var(--accent); }
.tabs a .tb-label { line-height: 1; }
.tabs a .tb-count { font-size: 11px; font-weight: 600; color: var(--text-2); background: #f1f2f4; border-radius: 20px; padding: 0 7px; height: 17px; min-width: 17px; display: inline-flex; align-items: center; justify-content: center; line-height: 1; }
.tabs a.active .tb-count { background: var(--accent-weak); color: var(--accent); }
/* toolbar */
.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.segmented { display: flex; background: #eceef1; border-radius: var(--radius); padding: 3px; gap: 2px; }
.segmented button { border: none; background: transparent; font-family: inherit; font-size: 13px; font-weight: 600; color: var(--text-2); padding: 5px 13px; border-radius: 4px; cursor: pointer; white-space: nowrap; }
.segmented button.active { background: #fff; color: var(--text); box-shadow: var(--shadow-sm); }
.mfilter { margin-left: auto; display: flex; align-items: center; gap: 7px; height: 36px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 11px; background: #fff; font-size: 13px; font-weight: 500; color: var(--text-2); cursor: pointer; white-space: nowrap; }
.mfilter svg { width: 15px; height: 15px; color: var(--text-3); }
/* activity feed */
.feed { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow-sm); padding: 6px 20px 18px; }
.day { font-size: 12px; font-weight: 700; color: var(--text-3); letter-spacing: .2px; padding: 18px 0 6px; }
.act { display: flex; align-items: center; gap: 13px; padding: 9px 0; position: relative; }
.act-ico { width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; flex-shrink: 0; z-index: 1; }
.act-ico svg { width: 16px; height: 16px; }
.act-ico.task { background: var(--blue-weak); color: var(--blue); }
.act-ico.member { background: var(--green-weak); color: var(--green); }
.act-ico.setting { background: var(--amber-weak); color: var(--amber); }
.act-main { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; }
.act-av { width: 22px; height: 22px; border-radius: 50%; display: grid; place-items: center; font-size: 10px; font-weight: 700; color: #fff; flex-shrink: 0; }
.act-text { font-size: 13.5px; color: var(--text-2); line-height: 1.5; }
.act-text b { color: var(--text); font-weight: 600; }
.act-text .tgt { color: var(--accent); font-weight: 600; text-decoration: none; }
.act-text .tgt:hover { text-decoration: underline; }
.act-text .st { font-weight: 600; }
.act-text .st.prog { color: var(--blue); } .act-text .st.done { color: var(--green); }
.act-time { font-size: 12px; color: var(--text-3); white-space: nowrap; flex-shrink: 0; margin-left: 12px; }
/* vertical connector line */
.feed-inner { position: relative; }
.act::before { content: ""; position: absolute; left: 15.5px; top: 50%; bottom: -9px; width: 1.5px; background: var(--border); z-index: 0; }
.act.last-in-group::before { display: none; }
</style>
</head>
<body>
<header class="topbar">
<div class="brand"><div class="logo">R</div>Relay</div>
<nav class="topnav">
<a href="내 업무.html">내 업무</a>
<a href="저장소 목록.html" class="active">저장소</a>
</nav>
<div class="topbar-right">
<button class="icon-btn" title="검색"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg></button>
<button class="icon-btn" title="알림"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg></button>
<div class="me"></div>
</div>
</header>
<div class="page" data-screen-label="저장소 활동">
<div class="crumb">
<a href="저장소 목록.html" class="lnk">저장소</a>
<span class="sep">/</span>
<a href="저장소 상세.html" class="lnk">3분기 신제품 런칭 캠페인</a>
<span class="sep">/</span>
<b>활동</b>
</div>
<!-- 저장소 헤더 -->
<div class="repo-head">
<div class="repo-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg></div>
<div class="rh-main">
<div class="rh-title">
<span class="rh-name">3분기 신제품 런칭 캠페인</span>
<span class="vis private"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>비공개</span>
</div>
<div class="rh-slug">marketing-team / q3-launch-campaign</div>
<div class="rh-desc">3분기 신제품(라인 클렌저) 런칭 캠페인 소재 제작 및 검수</div>
<div class="rh-meta">
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>main</span>
<span class="mi avstack"><span class="avatar av-f"></span><span class="avatar av-a"></span><span class="avatar av-b"></span><span class="avatar av-c"></span><span class="avatar av-d"></span></span>
<span class="mi">2시간 전 업데이트</span>
</div>
</div>
<div class="rh-actions">
<a class="btn primary" href="업무 작성.html"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5v14"/></svg>새 업무</a>
</div>
</div>
<!-- 서브탭 -->
<nav class="tabs">
<a href="저장소 상세.html"><span class="tb-label">업무</span> <span class="tb-count">12</span></a>
<a href="저장소 멤버.html"><span class="tb-label">멤버</span> <span class="tb-count">5</span></a>
<a href="#" class="active"><span class="tb-label">활동</span></a>
<a href="저장소 설정.html"><span class="tb-label">설정</span></a>
</nav>
<!-- 툴바 -->
<div class="toolbar">
<div class="segmented">
<button class="active">전체</button>
<button>업무</button>
<button>멤버</button>
<button>설정</button>
</div>
<div class="mfilter">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
모든 멤버
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
</div>
</div>
<!-- 활동 피드 -->
<div class="feed">
<div class="feed-inner">
<div class="day">오늘</div>
<div class="act">
<span class="act-ico task"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg></span>
<div class="act-main">
<span class="act-av av-c"></span>
<span class="act-text"><b>이도윤</b>님이 <a class="tgt" href="#">법무 검토 요청 및 회신 반영</a> 업무를 <span class="st prog">진행 중</span>으로 변경했습니다</span>
</div>
<span class="act-time">2시간 전</span>
</div>
<div class="act">
<span class="act-ico task"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg></span>
<div class="act-main">
<span class="act-av av-b"></span>
<span class="act-text"><b>박지훈</b>님이 <a class="tgt" href="#">메인 배너 이미지 2종 디자인</a>의 체크리스트 ‘가로형 메인 배너’를 완료했습니다</span>
</div>
<span class="act-time">3시간 전</span>
</div>
<div class="act">
<span class="act-ico member"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M19 8v6M22 11h-6"/></svg></span>
<div class="act-main">
<span class="act-av av-f"></span>
<span class="act-text"><b>정태경</b>님이 <b>한지민</b>님을 멤버로 초대했습니다</span>
</div>
<span class="act-time">5시간 전</span>
</div>
<div class="act last-in-group">
<span class="act-ico member"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="m17 11 2 2 4-4"/></svg></span>
<div class="act-main">
<span class="act-av av-d"></span>
<span class="act-text"><b>한지민</b>님이 멤버로 합류했습니다</span>
</div>
<span class="act-time">5시간 전</span>
</div>
<div class="day">어제</div>
<div class="act">
<span class="act-ico task"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg></span>
<div class="act-main">
<span class="act-av av-d"></span>
<span class="act-text"><b>최유진</b>님이 <a class="tgt" href="#">메인 비주얼 컨셉 확정</a> 업무를 <span class="st done">완료</span>했습니다</span>
</div>
<span class="act-time">어제 18:40</span>
</div>
<div class="act last-in-group">
<span class="act-ico task"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg></span>
<div class="act-main">
<span class="act-av av-f"></span>
<span class="act-text"><b>정태경</b>님이 <a class="tgt" href="#">SNS 콘텐츠 1차 시안</a> 업무를 지시했습니다 <span style="color:var(--text-3)">· 담당 정민호</span></span>
</div>
<span class="act-time">어제 14:10</span>
</div>
<div class="day">이번 주</div>
<div class="act">
<span class="act-ico setting"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>
<div class="act-main">
<span class="act-av av-f"></span>
<span class="act-text"><b>정태경</b>님이 저장소 공개 범위를 <b>비공개</b>로 변경했습니다</span>
</div>
<span class="act-time">6월 11일</span>
</div>
<div class="act">
<span class="act-ico member"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 11h-6"/></svg></span>
<div class="act-main">
<span class="act-av av-b"></span>
<span class="act-text"><b>박지훈</b>님의 역할이 <b>멤버</b>로 설정되었습니다</span>
</div>
<span class="act-time">6월 11일</span>
</div>
<div class="act">
<span class="act-ico setting"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg></span>
<div class="act-main">
<span class="act-av av-f"></span>
<span class="act-text"><b>정태경</b>님이 표시 제목을 ‘3분기 신제품 런칭 캠페인’으로 변경했습니다</span>
</div>
<span class="act-time">6월 10일</span>
</div>
<div class="act">
<span class="act-ico task"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg></span>
<div class="act-main">
<span class="act-av av-a"></span>
<span class="act-text"><b>김서연</b>님이 <a class="tgt" href="#">캠페인 핵심 메시지 시안 작성</a> 업무를 <span class="st done">완료</span>했습니다</span>
</div>
<span class="act-time">6월 9일</span>
</div>
<div class="act last-in-group">
<span class="act-ico setting"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg></span>
<div class="act-main">
<span class="act-av av-f"></span>
<span class="act-text"><b>정태경</b>님이 저장소를 생성했습니다</span>
</div>
<span class="act-time">6월 2일</span>
</div>
</div>
</div>
</div>
</body>
</html>
+222
View File
@@ -0,0 +1,222 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>회원가입 — Relay</title>
<link rel="preconnect" href="https://cdn.jsdelivr.net">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f4f5f7; --panel: #ffffff; --border: #e6e8ec; --border-strong: #d7dae0;
--text: #16181d; --text-2: #5b626e; --text-3: #9298a3;
--accent: #4f46e5; --accent-hover: #4338ca; --accent-weak: #eef0fe; --accent-border: #c7ccfb;
--green: #1f9d57; --kakao: #FEE500; --kakao-text: #191600;
--radius: 8px; --radius-sm: 5px;
}
html, body { height: 100%; }
body { background: var(--bg); color: var(--text); font-family: "Pretendard", -apple-system, system-ui, sans-serif; font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
.auth { display: flex; min-height: 100vh; }
/* brand panel */
.brand-panel { flex: 0 0 46%; max-width: 620px; position: relative; overflow: hidden; background: linear-gradient(155deg, #4f46e5 0%, #4338ca 52%, #2e2a8f 100%); color: #fff; padding: 44px 48px; display: flex; flex-direction: column; }
.brand-panel .blob { position: absolute; border-radius: 50%; pointer-events: none; }
.brand-panel .b1 { width: 360px; height: 360px; right: -120px; top: -90px; background: rgba(255,255,255,.10); }
.brand-panel .b2 { width: 260px; height: 260px; left: -90px; bottom: -70px; background: rgba(255,255,255,.07); }
.brand-panel .ring { position: absolute; right: 60px; bottom: 80px; width: 180px; height: 180px; border: 1.5px solid rgba(255,255,255,.14); border-radius: 50%; }
.brand-panel .ring::after { content: ""; position: absolute; inset: 34px; border: 1.5px solid rgba(255,255,255,.10); border-radius: 50%; }
.bp-logo { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 18px; letter-spacing: -.2px; position: relative; z-index: 1; }
.bp-logo .logo { width: 32px; height: 32px; border-radius: 9px; background: rgba(255,255,255,.16); border: 1px solid rgba(255,255,255,.22); display: grid; place-items: center; font-size: 17px; font-weight: 800; }
.bp-body { margin-top: auto; position: relative; z-index: 1; }
.bp-head { font-size: 34px; font-weight: 700; line-height: 1.32; letter-spacing: -.8px; text-wrap: balance; }
.bp-sub { margin-top: 18px; font-size: 15px; line-height: 1.7; color: rgba(255,255,255,.82); max-width: 400px; }
.bp-points { margin-top: 30px; display: flex; flex-direction: column; gap: 12px; }
.bp-point { display: flex; align-items: center; gap: 11px; font-size: 14px; color: rgba(255,255,255,.92); }
.bp-point .ic { width: 22px; height: 22px; border-radius: 50%; background: rgba(255,255,255,.16); display: grid; place-items: center; flex-shrink: 0; }
.bp-point .ic svg { width: 13px; height: 13px; }
.bp-foot { margin-top: 40px; font-size: 12.5px; color: rgba(255,255,255,.6); position: relative; z-index: 1; }
/* form panel */
.form-panel { flex: 1; display: flex; align-items: center; justify-content: center; padding: 40px 24px; }
.auth-card { width: 100%; max-width: 384px; }
.ac-logo { display: none; }
.ac-title { font-size: 24px; font-weight: 700; letter-spacing: -.5px; }
.ac-sub { color: var(--text-2); font-size: 14px; margin-top: 7px; }
.socials { display: flex; flex-direction: column; gap: 10px; margin-top: 26px; }
.sbtn { height: 46px; border-radius: var(--radius); border: 1px solid var(--border-strong); background: #fff; display: flex; align-items: center; justify-content: center; gap: 10px; cursor: pointer; font-family: inherit; font-size: 14.5px; font-weight: 600; color: var(--text); text-decoration: none; position: relative; transition: background .12s; }
.sbtn:hover { background: #f7f8fa; }
.sbtn .sico { position: absolute; left: 16px; display: grid; place-items: center; }
.sbtn.kakao { background: var(--kakao); border-color: var(--kakao); color: var(--kakao-text); }
.sbtn.kakao:hover { background: #f2d900; }
.divider { display: flex; align-items: center; gap: 14px; margin: 20px 0; color: var(--text-3); font-size: 12.5px; }
.divider::before, .divider::after { content: ""; height: 1px; background: var(--border); flex: 1; }
.field { margin-bottom: 13px; }
.flabel { font-size: 13px; font-weight: 600; color: var(--text-2); margin-bottom: 7px; display: block; }
.tinput { width: 100%; height: 44px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 13px; font-family: inherit; font-size: 14px; color: var(--text); background: #fff; }
.tinput:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
.tinput::placeholder { color: var(--text-3); }
.pw-wrap { position: relative; }
.pw-wrap .tinput { padding-right: 44px; }
.pw-toggle { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); width: 34px; height: 34px; border: none; background: transparent; color: var(--text-3); display: grid; place-items: center; cursor: pointer; border-radius: 6px; }
.pw-toggle:hover { background: #f1f2f4; color: var(--text-2); }
.pw-toggle svg { width: 18px; height: 18px; }
.field-hint { font-size: 12px; color: var(--text-3); margin-top: 7px; display: flex; align-items: center; gap: 5px; }
.field-hint svg { width: 13px; height: 13px; flex-shrink: 0; }
.terms { display: flex; align-items: flex-start; gap: 9px; margin: 6px 0 18px; font-size: 13px; color: var(--text-2); cursor: pointer; user-select: none; line-height: 1.5; }
.terms .box { width: 18px; height: 18px; border-radius: 5px; border: 1.5px solid var(--border-strong); display: grid; place-items: center; background: #fff; flex-shrink: 0; margin-top: 1px; }
.terms .box.on { background: var(--accent); border-color: var(--accent); }
.terms .box svg { width: 12px; height: 12px; color: #fff; opacity: 0; }
.terms .box.on svg { opacity: 1; }
.terms a { color: var(--accent); font-weight: 600; text-decoration: none; }
.terms a:hover { text-decoration: underline; }
.submit { width: 100%; height: 46px; border-radius: var(--radius); border: none; background: var(--accent); color: #fff; font-family: inherit; font-size: 15px; font-weight: 700; cursor: pointer; box-shadow: 0 1px 2px rgba(79,70,229,.4); display: flex; align-items: center; justify-content: center; gap: 7px; }
.submit:hover { background: var(--accent-hover); }
.submit svg { width: 17px; height: 17px; }
.alt { margin-top: 22px; text-align: center; font-size: 13.5px; color: var(--text-2); }
.alt a { color: var(--accent); font-weight: 600; text-decoration: none; }
.alt a:hover { text-decoration: underline; }
/* verification sent state */
.verify { text-align: center; display: none; }
.verify .vicon { width: 66px; height: 66px; border-radius: 50%; background: var(--accent-weak); color: var(--accent); display: grid; place-items: center; margin: 0 auto 22px; }
.verify .vicon svg { width: 30px; height: 30px; }
.verify h1 { font-size: 23px; font-weight: 700; letter-spacing: -.4px; }
.verify .vtext { color: var(--text-2); font-size: 14px; line-height: 1.7; margin-top: 12px; }
.verify .vmail { display: inline-flex; align-items: center; gap: 6px; margin-top: 16px; padding: 9px 14px; background: #f7f8fa; border: 1px solid var(--border); border-radius: var(--radius); font-size: 14px; font-weight: 600; color: var(--text); }
.verify .vmail svg { width: 15px; height: 15px; color: var(--text-3); }
.verify .vspam { font-size: 12.5px; color: var(--text-3); margin-top: 18px; line-height: 1.6; }
.verify .vactions { margin-top: 26px; display: flex; flex-direction: column; gap: 10px; }
.btn-ghost { height: 44px; border-radius: var(--radius); border: 1px solid var(--border-strong); background: #fff; color: var(--text); font-family: inherit; font-size: 14px; font-weight: 600; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 7px; text-decoration: none; }
.btn-ghost:hover { background: #f7f8fa; }
.btn-ghost svg { width: 16px; height: 16px; }
.vchange { background: none; border: none; font-family: inherit; font-size: 13px; color: var(--text-2); cursor: pointer; text-decoration: underline; text-underline-offset: 2px; }
.vchange:hover { color: var(--text); }
@media (max-width: 880px) {
.brand-panel { display: none; }
.ac-logo { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 17px; margin-bottom: 24px; }
.ac-logo .logo { width: 28px; height: 28px; border-radius: 8px; background: linear-gradient(135deg, #5b54f0, #4338ca); display: grid; place-items: center; color: #fff; font-size: 15px; font-weight: 800; }
}
</style>
</head>
<body>
<div class="auth">
<aside class="brand-panel">
<div class="blob b1"></div>
<div class="blob b2"></div>
<div class="ring"></div>
<div class="bp-logo"><span class="logo">R</span> Relay</div>
<div class="bp-body">
<div class="bp-head">팀에 합류하고<br>업무 지시를 시작하세요</div>
<div class="bp-sub">계정을 만들면 저장소에 초대되어, 받은 업무를 확인하고 직접 업무를 지시할 수 있습니다.</div>
<div class="bp-points">
<div class="bp-point"><span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span> 이메일로 1분이면 가입 완료</div>
<div class="bp-point"><span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span> 이메일 인증으로 안전한 가입</div>
<div class="bp-point"><span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span> 가입 후 바로 업무 흐름에 참여</div>
</div>
</div>
<div class="bp-foot">© 2026 Relay · 사내 업무 지시 플랫폼</div>
</aside>
<main class="form-panel">
<div class="auth-card">
<div class="ac-logo"><span class="logo">R</span> Relay</div>
<!-- ===== 상태 1: 가입 폼 ===== -->
<div class="signup-form">
<h1 class="ac-title">이메일로 회원가입</h1>
<p class="ac-sub">구글·카카오 계정은 <a href="로그인.html" style="color:var(--accent);font-weight:600;text-decoration:none">로그인 화면</a>에서 바로 시작할 수 있어요.</p>
<div class="field" style="margin-top:26px">
<label class="flabel">이름</label>
<input class="tinput" type="text" placeholder="홍길동" value="">
</div>
<div class="field">
<label class="flabel">이메일</label>
<input class="tinput" id="email" type="email" placeholder="name@acme.co" value="">
<div class="field-hint"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 6-10 7L2 6"/></svg> 가입 후 이 주소로 인증 메일이 발송됩니다.</div>
</div>
<div class="field">
<label class="flabel">비밀번호</label>
<div class="pw-wrap">
<input class="tinput" type="password" placeholder="비밀번호 입력" value="">
<button class="pw-toggle" type="button" title="비밀번호 표시"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
</div>
<div class="field-hint"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></svg> 8자 이상, 영문과 숫자를 포함해주세요.</div>
</div>
<div class="field">
<label class="flabel">비밀번호 확인</label>
<div class="pw-wrap">
<input class="tinput" type="password" placeholder="비밀번호 다시 입력" value="">
<button class="pw-toggle" type="button" title="비밀번호 표시"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
</div>
</div>
<label class="terms" id="terms">
<span class="box on"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
<span><a href="#">이용약관</a><a href="#">개인정보 처리방침</a>에 동의합니다. (필수)</span>
</label>
<button class="submit" type="button" id="submitBtn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 6-10 7L2 6"/></svg>
인증 메일 받고 가입하기
</button>
<div class="alt">이미 계정이 있으신가요? <a href="로그인.html">로그인</a></div>
</div>
<!-- ===== 상태 2: 인증 메일 전송됨 ===== -->
<div class="verify" id="verifyView">
<div class="vicon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 6-10 7L2 6"/></svg></div>
<h1>이메일을 확인해주세요</h1>
<div class="vtext">아래 주소로 인증 메일을 보냈습니다.<br>메일 속 링크를 클릭하면 가입이 완료됩니다.</div>
<div class="vmail"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 6-10 7L2 6"/></svg><span id="verifyEmail">name@acme.co</span></div>
<div class="vspam">메일이 보이지 않나요? 스팸함을 확인하거나 잠시 후 다시 시도해주세요.</div>
<div class="vactions">
<button class="btn-ghost" type="button"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg> 인증 메일 다시 보내기</button>
<button class="vchange" type="button" id="backBtn">다른 이메일로 가입하기</button>
</div>
</div>
</div>
</main>
</div>
<script>
// 비밀번호 표시 토글
document.querySelectorAll('.pw-toggle').forEach(function (btn) {
btn.addEventListener('click', function () {
var input = this.parentElement.querySelector('input');
input.type = input.type === 'password' ? 'text' : 'password';
});
});
// 약관 동의 토글
document.getElementById('terms').addEventListener('click', function (e) {
if (e.target.closest('a')) return;
e.preventDefault();
this.querySelector('.box').classList.toggle('on');
});
// 가입하기 → 인증 메일 전송 상태로 전환
document.getElementById('submitBtn').addEventListener('click', function () {
var email = document.getElementById('email').value.trim() || 'name@acme.co';
document.getElementById('verifyEmail').textContent = email;
document.querySelector('.signup-form').style.display = 'none';
document.getElementById('verifyView').style.display = 'block';
});
// 다른 이메일로 가입하기 → 폼으로 복귀
document.getElementById('backBtn').addEventListener('click', function () {
document.getElementById('verifyView').style.display = 'none';
document.querySelector('.signup-form').style.display = 'block';
});
</script>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
# ==========================================
# 백엔드 고유 설정 예시 — 복사해 .env.development 생성
# 루트 .env 에서 내려주지 않는 값만 작성한다 (JWT 등)
# ==========================================
# JWT 시크릿 (access/refresh 분리) — 임의의 강력한 랜덤 문자열로 교체
# 생성 예: node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
JWT_ACCESS_SECRET=change-me-access-secret
JWT_REFRESH_SECRET=change-me-refresh-secret
# 토큰 만료 시간
JWT_ACCESS_TTL=15m
JWT_REFRESH_TTL=14d
# --- 단독 실행(non-Docker) 시 주석 해제 ---
# NODE_ENV=development
# DB_HOST=localhost
# DB_PORT=5532
# DB_USER=dev_ttipo
# DB_PASSWORD=dev_960426
# DB_NAME=comrelay_db
# FRONTEND_URL=http://localhost:5173
# CORS_ORIGIN=http://localhost:5173
+23
View File
@@ -0,0 +1,23 @@
# ==========================================
# 백엔드 고유 설정 예시 — 복사해 .env.development 생성
# 루트 .env 에서 내려주지 않는 값만 작성한다 (JWT 등)
# ==========================================
# JWT 시크릿 (access/refresh 분리) — 임의의 강력한 랜덤 문자열로 교체
# 생성 예: node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
JWT_ACCESS_SECRET=change-me-access-secret
JWT_REFRESH_SECRET=change-me-refresh-secret
# 토큰 만료 시간
JWT_ACCESS_TTL=15m
JWT_REFRESH_TTL=14d
# --- 단독 실행(non-Docker) 시 주석 해제 ---
# NODE_ENV=development
# DB_HOST=localhost
# DB_PORT=5532
# DB_USER=dev_ttipo
# DB_PASSWORD=dev_960426
# DB_NAME=comrelay_db
# FRONTEND_URL=http://localhost:5173
# CORS_ORIGIN=http://localhost:5173
+58
View File
@@ -0,0 +1,58 @@
# compiled output
/dist
/node_modules
/build
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
.env.development
.env.production
# temp directory
.temp
.tmp
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
+4
View File
@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}
+33
View File
@@ -0,0 +1,33 @@
FROM node:20-alpine AS builder
WORKDIR /app
# 호스트의 npm 11 이 작성한 package-lock.json 형식과 호환되도록 npm 을 11 로 업그레이드
RUN npm install -g npm@11
# 재현 가능한 빌드를 위해 npm ci 사용 (lockfile 기반)
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
# 운영 stage 도 동일 npm 버전 사용
RUN npm install -g npm@11
# 운영용 의존성만 설치
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
EXPOSE 3000
# 비-root 실행 (보안 규칙)
USER node
CMD ["node", "dist/main"]
+98
View File
@@ -0,0 +1,98 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
$ npm install
```
## Compile and run the project
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Run tests
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ npm install -g @nestjs/mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
+35
View File
@@ -0,0 +1,35 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }],
},
},
);
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"builder": "swc",
"typeCheck": true
}
}
+13713
View File
File diff suppressed because it is too large Load Diff
+99
View File
@@ -0,0 +1,99 @@
{
"name": "backend",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/cache-manager": "^3.0.1",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/schedule": "^6.1.1",
"@nestjs/swagger": "^11.2.6",
"@nestjs/terminus": "^11.0.0",
"@nestjs/throttler": "^6.4.0",
"@nestjs/typeorm": "^11.0.1",
"bcryptjs": "^3.0.3",
"cache-manager": "^6.4.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.4",
"cookie-parser": "^1.4.7",
"express-rate-limit": "^8.3.2",
"helmet": "^8.1.0",
"nest-winston": "^1.10.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pg": "^8.20.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^0.3.28",
"winston": "^3.17.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@swc/cli": "^0.8.1",
"@swc/core": "^1.15.24",
"@types/bcryptjs": "^2.4.6",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^22.10.7",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2",
"chokidar": "^5.0.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^16.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}
+22
View File
@@ -0,0 +1,22 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});
+16
View File
@@ -0,0 +1,16 @@
import { Controller, Get } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { AppService } from './app.service';
// 루트 컨트롤러 — 헬스체크/디버그 용 단순 엔드포인트
@ApiTags('App')
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get('hello')
@ApiOperation({ summary: '동작 확인용 Hello World' })
getHello(): string {
return this.appService.getHello();
}
}
+76
View File
@@ -0,0 +1,76 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { CacheModule } from '@nestjs/cache-manager';
import { APP_GUARD } from '@nestjs/core';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { HealthModule } from './modules/health/health.module';
import { UserModule } from './modules/user/user.module';
import { AuthModule } from './modules/auth/auth.module';
import { RepoModule } from './modules/repo/repo.module';
@Module({
imports: [
ScheduleModule.forRoot(),
ConfigModule.forRoot({
isGlobal: true,
envFilePath: `.env.${process.env.NODE_ENV || 'development'}`,
}),
// 전역 캐시 매니저 — Redis 도입 시 store 옵션을 redisStore 로 변경
CacheModule.register({
isGlobal: true,
ttl: 60_000, // 60초 기본 TTL
}),
// 전역 Rate Limiter — main.ts 의 express-rate-limit 와 별개로 라우트 단위 제어 가능
ThrottlerModule.forRoot([
{
ttl: 60_000,
limit: 100,
},
]),
TypeOrmModule.forRootAsync({
useFactory: () => {
const isProduction = process.env.NODE_ENV === 'production';
return {
type: 'postgres',
host: process.env.DB_HOST || 'db',
port: parseInt(process.env.DB_PORT || '5432', 10),
username:
process.env.DB_USER || process.env.DB_USERNAME || 'postgres',
password: process.env.DB_PASSWORD || 'password',
database:
process.env.DB_NAME || process.env.DB_DATABASE || 'project_db',
autoLoadEntities: true,
// 운영 환경에서는 절대 synchronize 사용 금지 (스키마 자동 변경 → 데이터 손실 위험)
synchronize: !isProduction,
migrationsRun: isProduction,
logging: !isProduction,
extra: {
max: 100,
connectionTimeoutMillis: 5000,
idleTimeoutMillis: 30000,
},
entities: [__dirname + '/**/*.entity{.ts,.js}'],
migrations: [__dirname + '/migrations/*{.ts,.js}'],
};
},
}),
HealthModule,
UserModule,
AuthModule,
RepoModule,
],
controllers: [AppController],
providers: [
AppService,
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
],
})
export class AppModule {}
+8
View File
@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}
@@ -0,0 +1,14 @@
import { HttpException, HttpStatus } from '@nestjs/common';
// 비즈니스 예외 — 에러 코드와 사용자 노출 문구를 명시적으로 지정한다.
// HttpExceptionFilter 가 { code, message } 를 그대로 표준 응답으로 변환한다.
// (status 기반 자동 매핑보다 우선 적용됨)
export class BusinessException extends HttpException {
constructor(
code: string,
message: string,
status: HttpStatus = HttpStatus.OK,
) {
super({ code, message }, status);
}
}
@@ -0,0 +1,101 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Response } from 'express';
// 전역 Exception Filter — CLAUDE.md 에러 코드 맵에 따라 표준 응답으로 변환
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
let status: HttpStatus = HttpStatus.INTERNAL_SERVER_ERROR;
let code = 'SYS_001';
let message =
'일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.';
if (exception instanceof HttpException) {
status = exception.getStatus();
const exceptionResponse = exception.getResponse();
// 0) 명시적 { code, message } 를 가진 비즈니스 예외 — status 매핑보다 우선
// (BusinessException 등에서 사용. 코드/문구를 그대로 사용자에게 노출)
const explicit = this.extractExplicitError(exceptionResponse);
if (explicit) {
response.status(status).json({ success: false, error: explicit });
return;
}
// 1) HTTP 상태 → 에러 코드 매핑
if (status === HttpStatus.UNAUTHORIZED) {
code = 'AUTH_001';
message = '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.';
} else if (status === HttpStatus.FORBIDDEN) {
code = 'AUTH_003';
message = '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.';
} else if (status === HttpStatus.NOT_FOUND) {
code = 'RES_001';
message = '요청하신 정보나 페이지를 찾을 수 없습니다.';
} else if (
status === HttpStatus.BAD_REQUEST ||
status === HttpStatus.UNPROCESSABLE_ENTITY
) {
code = 'VAL_001';
message =
'입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)';
} else {
code = 'BIZ_001';
message = '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.';
// 비즈니스 예외에서는 사용자에게 보일 메시지를 재정의 가능
const overridden = this.extractMessage(exceptionResponse);
if (overridden) {
message = overridden;
}
}
} else {
// 정의되지 않은 예외 → 운영팀이 추적 가능하도록 로그 + SYS_001 응답 유지
this.logger.error('Unhandled Exception', exception as Error);
}
response.status(status).json({
success: false,
error: {
code,
message,
},
});
}
// 예외 응답에서 명시적 { code, message } 추출 (둘 다 문자열일 때만)
private extractExplicitError(
res: string | object,
): { code: string; message: string } | null {
if (typeof res === 'object' && res !== null) {
const r = res as { code?: unknown; message?: unknown };
if (typeof r.code === 'string' && typeof r.message === 'string') {
return { code: r.code, message: r.message };
}
}
return null;
}
// 예외 응답에서 사용자 노출 메시지(문자열) 추출
private extractMessage(res: string | object): string | null {
if (typeof res === 'object' && res !== null && 'message' in res) {
const responseMessage = res.message;
if (typeof responseMessage === 'string') {
return responseMessage;
}
}
return null;
}
}
@@ -0,0 +1,32 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
export interface StandardResponse<T> {
success: boolean;
data: T | null;
}
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<
T,
StandardResponse<T>
> {
intercept(
_context: ExecutionContext,
next: CallHandler<T>,
): Observable<StandardResponse<T>> {
// 이미 Http 응답객체인 경우 등 예외처리가 필요할 수 있으나 기본적으로 data 래핑
return next.handle().pipe(
map((data: T) => ({
success: true,
data: data ?? null,
})),
);
}
}
+115
View File
@@ -0,0 +1,115 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { Logger, RequestMethod, ValidationPipe } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { WinstonModule } from 'nest-winston';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
import cookieParser from 'cookie-parser';
import type {
Express,
Request as ExpressRequest,
Response as ExpressResponse,
} from 'express';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
import { winstonConfig } from './shared/logger/winston.config';
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
logger: WinstonModule.createLogger(winstonConfig),
});
// 리버스 프록시(Nginx, Docker 배포 환경 등) 뒤에서 올바른 클라이언트 IP 식별 지원을 위해 설정합니다.
const expressApp = app.getHttpAdapter().getInstance() as Express;
expressApp.set('trust proxy', 1);
// 글로벌 prefix — 모든 라우트가 /api/* 로 정규화됨 (Flutter, Web 양쪽 baseURL과 일치)
app.setGlobalPrefix('api', {
// 헬스체크 / 루트 상태 엔드포인트는 prefix 미적용
exclude: [{ path: '/', method: RequestMethod.GET }],
});
// 기본 주소('/') 접속 시 상태 확인용 텍스트 응답 추가
expressApp.get('/', (_req: ExpressRequest, res: ExpressResponse) => {
res.send('Backend API Server is running 정상 구동 중입니다.');
});
// 0. 쿠키 파서 — HttpOnly 인증 쿠키(access/refresh) 파싱
app.use(cookieParser());
// 1. 보안 헤더 (Helmet) - XSS 방지 및 기본 웹 보안
app.use(helmet());
// 2. 화이트리스트 기반 CORS 정책
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
const corsOriginEnv = process.env.CORS_ORIGIN;
app.enableCors({
origin: (
origin: string | undefined,
callback: (err: Error | null, allow?: boolean) => void,
) => {
// 서버간의 통신(origin이 undefined)이거나 명시된 리스트일 때만 허용
const whitelist = [frontendUrl, 'http://localhost:3000'];
if (corsOriginEnv) whitelist.push(corsOriginEnv);
if (!origin || whitelist.indexOf(origin) !== -1) {
callback(null, true);
} else {
// 클라이언트에는 403 금지 에러로 리턴됨
callback(new Error('CORS 정책에 의해 차단된 도메인입니다.'));
}
},
credentials: true, // 쿠키 교환 허용
});
// 3. 글로벌 Rate Limiting 적용 (DDoS, 브루트포스 예방)
app.use(
rateLimit({
windowMs: 15 * 60 * 1000, // 15분
max: 150, // 15분 IP당 최대 150개 요청
message: {
success: false,
error: {
code: 'SYS_001',
message:
'일시적인 시스템 오류가 발생했습니다. (요청 한도 초과) 잠시 후 다시 시도해 주세요.', // 글로벌 통합 에러 포맷
},
},
}),
);
// 4. 전역 DTO 유효성 파이프 (강력한 검증 단계)
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // DTO에 불필요한 값이 오면 삭제
forbidNonWhitelisted: true, // DTO에 없는 필드가 주입되면 400 에러 발생
transform: true, // 네트워크 데이터를 DTO나 기본 타입으로 자동 변환
}),
);
// 5. 프론트엔드 연동을 위한 규격화된 에러 & 성공 페이로드 인터셉터 적용
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new TransformInterceptor());
// 6. Swagger API 문서 설정
const config = new DocumentBuilder()
.setTitle('Backend API Documentation')
.setDescription('The API documentation conforming to the global guidelines')
.setVersion('1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api-docs', app, document);
const port = Number(process.env.PORT ?? 3000);
await app.listen(port, '0.0.0.0');
Logger.log(`🚀 Backend running on http://localhost:${port}/api`, 'Bootstrap');
Logger.log(
`📘 Swagger docs at http://localhost:${port}/api-docs`,
'Bootstrap',
);
}
void bootstrap();
+142
View File
@@ -0,0 +1,142 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Post,
Res,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { ConfigService } from '@nestjs/config';
import type { CookieOptions, Response } from 'express';
import { AuthService } from './auth.service';
import { SignupDto } from './dto/signup.dto';
import { LoginDto } from './dto/login.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { JwtRefreshGuard } from './guards/jwt-refresh.guard';
import { CurrentUser } from './decorators/current-user.decorator';
import type { PublicUser } from '../user/user.service';
// 쿠키 만료 시간(ms)
const ACCESS_MAX_AGE = 15 * 60 * 1000; // 15분
const REFRESH_MAX_AGE = 14 * 24 * 60 * 60 * 1000; // 14일
// refresh 쿠키 전송 경로 — /auth/* 요청에만 첨부 (글로벌 prefix 'api' 포함)
const REFRESH_COOKIE_PATH = '/api/auth';
// 인증 컨트롤러 — 라우팅/쿠키 발급만 담당, 비즈니스 로직은 AuthService 위임
@ApiTags('Auth')
@Controller('auth')
export class AuthController {
constructor(
private readonly authService: AuthService,
private readonly config: ConfigService,
) {}
// HttpOnly·Secure 쿠키 공통 옵션
private cookieBase(): CookieOptions {
const isProd = this.config.get<string>('NODE_ENV') === 'production';
return {
httpOnly: true,
secure: isProd, // 운영: HTTPS 전용
sameSite: isProd ? 'none' : 'lax', // 운영: 크로스 도메인 허용
};
}
// access/refresh 토큰을 쿠키로 설정
private setAuthCookies(
res: Response,
accessToken: string,
refreshToken: string,
): void {
res.cookie('access_token', accessToken, {
...this.cookieBase(),
path: '/',
maxAge: ACCESS_MAX_AGE,
});
res.cookie('refresh_token', refreshToken, {
...this.cookieBase(),
path: REFRESH_COOKIE_PATH,
maxAge: REFRESH_MAX_AGE,
});
}
// 인증 쿠키 제거 (로그아웃)
private clearAuthCookies(res: Response): void {
res.clearCookie('access_token', { ...this.cookieBase(), path: '/' });
res.clearCookie('refresh_token', {
...this.cookieBase(),
path: REFRESH_COOKIE_PATH,
});
}
@Post('signup')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: '회원가입 (가입 후 자동 로그인)' })
@ApiResponse({ status: 201, description: '가입 성공, 인증 쿠키 발급' })
@ApiResponse({ status: 409, description: '이미 가입된 이메일 (BIZ_001)' })
async signup(
@Body() dto: SignupDto,
@Res({ passthrough: true }) res: Response,
): Promise<PublicUser> {
const user = await this.authService.signup(dto);
const tokens = await this.authService.issueTokens(user);
this.setAuthCookies(res, tokens.accessToken, tokens.refreshToken);
return user;
}
@Post('login')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '로그인' })
@ApiResponse({ status: 200, description: '로그인 성공, 인증 쿠키 발급' })
@ApiResponse({
status: 401,
description: '이메일/비밀번호 불일치 (BIZ_001)',
})
async login(
@Body() dto: LoginDto,
@Res({ passthrough: true }) res: Response,
): Promise<PublicUser> {
const user = await this.authService.validateUser(dto);
const tokens = await this.authService.issueTokens(user);
this.setAuthCookies(res, tokens.accessToken, tokens.refreshToken);
return user;
}
@Post('logout')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '로그아웃 (인증 쿠키 제거)' })
@ApiResponse({ status: 200, description: '로그아웃 성공' })
logout(@Res({ passthrough: true }) res: Response): null {
this.clearAuthCookies(res);
return null;
}
@Post('refresh')
@HttpCode(HttpStatus.OK)
@UseGuards(JwtRefreshGuard)
@ApiOperation({ summary: 'access 토큰 재발급 (refresh 쿠키 필요)' })
@ApiResponse({ status: 200, description: '토큰 재발급 성공' })
@ApiResponse({
status: 401,
description: 'refresh 토큰 만료/누락 (AUTH_001)',
})
async refresh(
@CurrentUser() user: PublicUser,
@Res({ passthrough: true }) res: Response,
): Promise<null> {
const tokens = await this.authService.issueTokens(user);
this.setAuthCookies(res, tokens.accessToken, tokens.refreshToken);
return null;
}
@Get('me')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: '현재 로그인 사용자 조회 (부트스트랩/가드용)' })
@ApiResponse({ status: 200, description: '사용자 조회 성공' })
@ApiResponse({ status: 401, description: '미인증 (AUTH_001)' })
me(@CurrentUser() user: PublicUser): PublicUser {
return user;
}
}
+22
View File
@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { UserModule } from '../user/user.module';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy';
// 인증 모듈 — JWT(access/refresh) + Passport 전략
@Module({
imports: [
UserModule,
PassportModule,
// 토큰 서명 옵션은 AuthService 에서 호출 단위로 지정하므로 기본 등록만 한다
JwtModule.register({}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy, JwtRefreshStrategy],
exports: [AuthService],
})
export class AuthModule {}
+83
View File
@@ -0,0 +1,83 @@
import { HttpStatus, Injectable } from '@nestjs/common';
import { JwtService, type JwtSignOptions } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcryptjs';
import { UserService, type PublicUser } from '../user/user.service';
import { BusinessException } from '../../common/exceptions/business.exception';
import { SignupDto } from './dto/signup.dto';
import { LoginDto } from './dto/login.dto';
// bcrypt 해시 라운드
const BCRYPT_ROUNDS = 10;
// 발급된 토큰 쌍
export interface TokenPair {
accessToken: string;
refreshToken: string;
}
// 인증 비즈니스 로직 — 가입, 로그인 검증, 토큰 발급
@Injectable()
export class AuthService {
constructor(
private readonly userService: UserService,
private readonly jwtService: JwtService,
private readonly config: ConfigService,
) {}
// 회원가입 — 이메일 중복 검사 후 비밀번호 해시하여 저장
async signup(dto: SignupDto): Promise<PublicUser> {
const exists = await this.userService.findByEmail(dto.email);
if (exists) {
// 409 + 비즈니스 코드/문구 명시 → 필터가 그대로 노출
throw new BusinessException(
'BIZ_001',
'이미 가입된 이메일입니다.',
HttpStatus.CONFLICT,
);
}
const passwordHash = await bcrypt.hash(dto.password, BCRYPT_ROUNDS);
const user = await this.userService.create({
email: dto.email,
passwordHash,
name: dto.name,
role: dto.role ?? null,
});
return UserService.toPublic(user);
}
// 로그인 검증 — 이메일/비밀번호 일치 확인
async validateUser(dto: LoginDto): Promise<PublicUser> {
const user = await this.userService.findByEmailWithPassword(dto.email);
if (!user || !(await bcrypt.compare(dto.password, user.passwordHash))) {
// 401 + 비즈니스 코드/문구 명시 → 자격 불일치를 명확히 안내
throw new BusinessException(
'BIZ_001',
'이메일 또는 비밀번호가 올바르지 않습니다.',
HttpStatus.UNAUTHORIZED,
);
}
return UserService.toPublic(user);
}
// access/refresh 토큰 발급
async issueTokens(user: PublicUser): Promise<TokenPair> {
const accessToken = await this.jwtService.signAsync(
{ sub: user.id, email: user.email },
{
secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'),
expiresIn: (this.config.get<string>('JWT_ACCESS_TTL') ??
'15m') as JwtSignOptions['expiresIn'],
},
);
const refreshToken = await this.jwtService.signAsync(
{ sub: user.id },
{
secret: this.config.getOrThrow<string>('JWT_REFRESH_SECRET'),
expiresIn: (this.config.get<string>('JWT_REFRESH_TTL') ??
'14d') as JwtSignOptions['expiresIn'],
},
);
return { accessToken, refreshToken };
}
}
@@ -0,0 +1,10 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import type { PublicUser } from '../../user/user.service';
// 현재 인증된 사용자 추출 데코레이터 — JwtAuthGuard 통과 후 req.user 를 반환
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): PublicUser => {
const request = ctx.switchToHttp().getRequest<{ user: PublicUser }>();
return request.user;
},
);
+30
View File
@@ -0,0 +1,30 @@
import {
IsBoolean,
IsEmail,
IsNotEmpty,
IsOptional,
IsString,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
// 로그인 DTO — 이메일/비밀번호 + 로그인 유지 여부
export class LoginDto {
@ApiProperty({ description: '사용자 이메일', example: 'sykim@acme.co' })
@IsEmail({}, { message: '올바른 이메일 형식을 입력해 주세요.' })
@IsNotEmpty({ message: '이메일은 필수 입력 항목입니다.' })
email!: string;
@ApiProperty({ description: '비밀번호', example: 'password123' })
@IsString()
@IsNotEmpty({ message: '비밀번호는 필수 입력 항목입니다.' })
password!: string;
@ApiProperty({
description: '로그인 상태 유지 여부',
example: true,
required: false,
})
@IsOptional()
@IsBoolean()
keepLogin?: boolean;
}
@@ -0,0 +1,35 @@
import {
IsEmail,
IsNotEmpty,
IsOptional,
IsString,
MinLength,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
// 회원가입 DTO — 이메일/비밀번호/이름
export class SignupDto {
@ApiProperty({ description: '사용자 이메일', example: 'sykim@acme.co' })
@IsEmail({}, { message: '올바른 이메일 형식을 입력해 주세요.' })
@IsNotEmpty({ message: '이메일은 필수 입력 항목입니다.' })
email!: string;
@ApiProperty({ description: '비밀번호 (최소 8자)', example: 'password123' })
@IsString()
@MinLength(8, { message: '비밀번호는 최소 8자 이상이어야 합니다.' })
password!: string;
@ApiProperty({ description: '사용자 이름', example: '김서연' })
@IsString()
@IsNotEmpty({ message: '이름은 필수 입력 항목입니다.' })
name!: string;
@ApiProperty({
description: '전사 직무(선택)',
example: '콘텐츠 기획',
required: false,
})
@IsOptional()
@IsString()
role?: string;
}
@@ -0,0 +1,6 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
// access 토큰 기반 인증 가드 — 보호 라우트에 적용 (미인증 시 401 → AUTH_001)
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
@@ -0,0 +1,6 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
// refresh 토큰 기반 가드 — /auth/refresh 전용
@Injectable()
export class JwtRefreshGuard extends AuthGuard('jwt-refresh') {}
@@ -0,0 +1,35 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import type { Request } from 'express';
import { UserService, type PublicUser } from '../../user/user.service';
import type { JwtPayload } from '../types/jwt-payload';
// refresh 토큰 검증 전략 — refresh_token 쿠키에서 토큰 추출
@Injectable()
export class JwtRefreshStrategy extends PassportStrategy(
Strategy,
'jwt-refresh',
) {
constructor(
config: ConfigService,
private readonly userService: UserService,
) {
super({
jwtFromRequest: (req: Request): string | null =>
(req?.cookies as Record<string, string> | undefined)?.refresh_token ??
null,
ignoreExpiration: false,
secretOrKey: config.getOrThrow<string>('JWT_REFRESH_SECRET'),
});
}
async validate(payload: JwtPayload): Promise<PublicUser> {
const user = await this.userService.findById(payload.sub);
if (!user) {
throw new UnauthorizedException();
}
return UserService.toPublic(user);
}
}
@@ -0,0 +1,34 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import type { Request } from 'express';
import { UserService, type PublicUser } from '../../user/user.service';
import type { JwtPayload } from '../types/jwt-payload';
// access 토큰 검증 전략 — access_token 쿠키에서 토큰 추출
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(
config: ConfigService,
private readonly userService: UserService,
) {
super({
// 쿠키(access_token)에서 JWT 추출
jwtFromRequest: (req: Request): string | null =>
(req?.cookies as Record<string, string> | undefined)?.access_token ??
null,
ignoreExpiration: false,
secretOrKey: config.getOrThrow<string>('JWT_ACCESS_SECRET'),
});
}
// 토큰 유효 시 실제 사용자 존재를 재확인하고 공개 형태로 반환 → req.user
async validate(payload: JwtPayload): Promise<PublicUser> {
const user = await this.userService.findById(payload.sub);
if (!user) {
throw new UnauthorizedException();
}
return UserService.toPublic(user);
}
}
@@ -0,0 +1,7 @@
// JWT 토큰 페이로드 — access/refresh 공통
export interface JwtPayload {
// 사용자 id (subject)
sub: string;
// 이메일 (access 토큰에만 포함)
email?: string;
}
@@ -0,0 +1,24 @@
import { Controller, Get } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import {
HealthCheck,
HealthCheckService,
TypeOrmHealthIndicator,
} from '@nestjs/terminus';
// 헬스체크 엔드포인트 — Docker healthcheck, 모니터링 시스템에서 사용
@ApiTags('Health')
@Controller('health')
export class HealthController {
constructor(
private readonly health: HealthCheckService,
private readonly db: TypeOrmHealthIndicator,
) {}
@Get()
@HealthCheck()
@ApiOperation({ summary: '애플리케이션 상태 확인' })
check() {
return this.health.check([() => this.db.pingCheck('database')]);
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { TerminusModule } from '@nestjs/terminus';
import { HealthController } from './health.controller';
@Module({
imports: [TerminusModule],
controllers: [HealthController],
})
export class HealthModule {}
@@ -0,0 +1,53 @@
import {
IsIn,
IsNotEmpty,
IsOptional,
IsString,
Matches,
MaxLength,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import type { RepoVisibility } from '../entities/repo.entity';
// 저장소 생성 DTO
export class CreateRepoDto {
@ApiProperty({
description: '저장소 slug(영문 소문자·숫자·하이픈·밑줄)',
example: 'q3-launch-campaign',
})
@IsString()
@IsNotEmpty({ message: '저장소 이름은 필수 입력 항목입니다.' })
@Matches(/^[a-z0-9-_]+$/, {
message:
'저장소 이름은 영문 소문자·숫자와 하이픈(-)·밑줄(_)만 사용할 수 있습니다.',
})
@MaxLength(60)
slug!: string;
@ApiProperty({
description: '표시 제목(한글)',
example: '3분기 신제품 런칭 캠페인',
})
@IsString()
@IsNotEmpty({ message: '표시 제목은 필수 입력 항목입니다.' })
@MaxLength(40)
name!: string;
@ApiProperty({
description: '공개 범위',
enum: ['private', 'public'],
example: 'private',
})
@IsIn(['private', 'public'], { message: '공개 범위를 선택해 주세요.' })
visibility!: RepoVisibility;
@ApiProperty({ description: '프로젝트 설명(선택)', required: false })
@IsOptional()
@IsString()
description?: string;
@ApiProperty({ description: '메인 브랜치', example: 'main', required: false })
@IsOptional()
@IsString()
branch?: string;
}
@@ -0,0 +1,31 @@
import { IsIn, IsOptional, IsString, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import type { RepoVisibility } from '../entities/repo.entity';
// 저장소 수정 DTO — 표시 제목/설명/공개범위/브랜치 (slug·owner 는 변경 불가)
export class UpdateRepoDto {
@ApiProperty({ description: '표시 제목(한글)', required: false })
@IsOptional()
@IsString()
@MaxLength(40)
name?: string;
@ApiProperty({ description: '프로젝트 설명', required: false })
@IsOptional()
@IsString()
description?: string;
@ApiProperty({
description: '공개 범위',
enum: ['private', 'public'],
required: false,
})
@IsOptional()
@IsIn(['private', 'public'], { message: '공개 범위를 선택해 주세요.' })
visibility?: RepoVisibility;
@ApiProperty({ description: '메인 브랜치', required: false })
@IsOptional()
@IsString()
branch?: string;
}
@@ -0,0 +1,46 @@
import {
Column,
CreateDateColumn,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
Unique,
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
import { Repo } from './repo.entity';
// 저장소 내 권한 역할
export type MemberRoleType = 'admin' | 'member';
// 저장소 멤버 (Repo ↔ User 조인 + 역할 정보)
@Entity('repo_members')
@Unique(['repo', 'user'])
export class RepoMember {
@PrimaryGeneratedColumn('uuid')
id!: string;
// 소속 저장소 (저장소 삭제 시 함께 삭제)
// Relation<> 래퍼: 엔티티 순환참조에서 메타데이터가 Repo 클래스를 즉시 참조하지 않도록 함
@ManyToOne(() => Repo, (repo) => repo.members, { onDelete: 'CASCADE' })
repo!: Relation<Repo>;
// 멤버 사용자 (사용자 삭제 시 함께 삭제) — 관계는 호출부에서 명시적으로 로딩
@ManyToOne(() => User, { onDelete: 'CASCADE' })
user!: User;
// 권한 역할 (admin: 저장소 관리, member: 일반)
@Column({ name: 'role_type', type: 'varchar', default: 'member' })
roleType!: MemberRoleType;
// 소유자 여부(저장소 생성자) — 역할 변경/제거 불가 대상
@Column({ name: 'is_owner', default: false })
isOwner!: boolean;
// 저장소 내 직무 표기(예: '리드', '콘텐츠 디자인') — 선택
@Column({ name: 'sub_role', type: 'varchar', nullable: true })
subRole!: string | null;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}
@@ -0,0 +1,55 @@
import {
Column,
CreateDateColumn,
Entity,
OneToMany,
PrimaryGeneratedColumn,
type Relation,
UpdateDateColumn,
} from 'typeorm';
import { RepoMember } from './repo-member.entity';
// 저장소 공개 범위
export type RepoVisibility = 'private' | 'public';
// 저장소 엔티티
@Entity('repos')
export class Repo {
// 내부 식별자 (UUID)
@PrimaryGeneratedColumn('uuid')
id!: string;
// 라우팅/URL 식별자(영문 slug 세그먼트, 예: 'q3-launch-campaign') — 전역 유일
@Column({ name: 'slug_name', unique: true })
slugName!: string;
// 소유 조직 slug (예: 'marketing-team') — 현재는 단일 조직 고정
@Column()
owner!: string;
// 표시 제목(한글, 예: '3분기 신제품 런칭 캠페인')
@Column()
name!: string;
// 프로젝트 설명(선택)
@Column({ type: 'varchar', nullable: true })
description!: string | null;
// 공개 범위
@Column({ type: 'varchar', default: 'private' })
visibility!: RepoVisibility;
// 메인 브랜치
@Column({ default: 'main' })
branch!: string;
// 멤버 목록
@OneToMany(() => RepoMember, (member) => member.repo)
members!: Relation<RepoMember>[];
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
}
@@ -0,0 +1,86 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { PublicUser } from '../user/user.service';
import { RepoService, type RepoResponse } from './repo.service';
import { CreateRepoDto } from './dto/create-repo.dto';
import { UpdateRepoDto } from './dto/update-repo.dto';
// 저장소 컨트롤러 — 모든 엔드포인트 인증 필요
@ApiTags('Repo')
@Controller('repos')
@UseGuards(JwtAuthGuard)
export class RepoController {
constructor(private readonly repoService: RepoService) {}
@Get()
@ApiOperation({ summary: '내가 멤버인 저장소 목록' })
@ApiResponse({ status: 200, description: '목록 조회 성공' })
findAll(@CurrentUser() user: PublicUser): Promise<RepoResponse[]> {
return this.repoService.findAllForUser(user.id);
}
@Post()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: '저장소 생성' })
@ApiResponse({ status: 201, description: '생성 성공' })
@ApiResponse({
status: 409,
description: '이미 사용 중인 저장소 이름 (BIZ_001)',
})
create(
@Body() dto: CreateRepoDto,
@CurrentUser() user: PublicUser,
): Promise<RepoResponse> {
return this.repoService.create(dto, user.id);
}
@Get(':repoId')
@ApiOperation({ summary: '저장소 단건 조회' })
@ApiResponse({ status: 200, description: '조회 성공' })
@ApiResponse({ status: 403, description: '접근 권한 없음 (AUTH_003)' })
@ApiResponse({ status: 404, description: '저장소를 찾을 수 없음 (RES_001)' })
findOne(
@Param('repoId') repoId: string,
@CurrentUser() user: PublicUser,
): Promise<RepoResponse> {
return this.repoService.findOneForUser(repoId, user.id);
}
@Patch(':repoId')
@ApiOperation({ summary: '저장소 수정 (admin)' })
@ApiResponse({ status: 200, description: '수정 성공' })
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
update(
@Param('repoId') repoId: string,
@Body() dto: UpdateRepoDto,
@CurrentUser() user: PublicUser,
): Promise<RepoResponse> {
return this.repoService.update(repoId, user.id, dto);
}
@Delete(':repoId')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '저장소 삭제 (소유자)' })
@ApiResponse({ status: 200, description: '삭제 성공' })
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
async remove(
@Param('repoId') repoId: string,
@CurrentUser() user: PublicUser,
): Promise<null> {
await this.repoService.remove(repoId, user.id);
return null;
}
}
+16
View File
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserModule } from '../user/user.module';
import { Repo } from './entities/repo.entity';
import { RepoMember } from './entities/repo-member.entity';
import { RepoService } from './repo.service';
import { RepoController } from './repo.controller';
// 저장소 모듈
@Module({
imports: [TypeOrmModule.forFeature([Repo, RepoMember]), UserModule],
controllers: [RepoController],
providers: [RepoService],
exports: [RepoService],
})
export class RepoModule {}
+191
View File
@@ -0,0 +1,191 @@
import {
ForbiddenException,
HttpStatus,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { UserService, type PublicUser } from '../user/user.service';
import { BusinessException } from '../../common/exceptions/business.exception';
import { Repo, type RepoVisibility } from './entities/repo.entity';
import { RepoMember } from './entities/repo-member.entity';
import { CreateRepoDto } from './dto/create-repo.dto';
import { UpdateRepoDto } from './dto/update-repo.dto';
// 현재는 단일 조직 고정 (조직 모델 도입 시 교체)
const DEFAULT_OWNER = 'marketing-team';
// 저장소 응답 형태 — 파생 라벨/진행률은 프론트에서 계산하므로 원시값 위주로 내린다
export interface RepoResponse {
id: string; // slugName (라우팅 식별자)
name: string;
owner: string;
slug: string; // owner/slugName
desc: string | null;
visibility: RepoVisibility;
branch: string;
members: PublicUser[];
memberCount: number;
doneCount: number;
totalCount: number;
updatedAt: string;
}
// 저장소 비즈니스 로직
@Injectable()
export class RepoService {
constructor(
@InjectRepository(Repo)
private readonly repoRepo: Repository<Repo>,
@InjectRepository(RepoMember)
private readonly memberRepo: Repository<RepoMember>,
private readonly userService: UserService,
) {}
// 내가 멤버인 저장소 목록
async findAllForUser(userId: string): Promise<RepoResponse[]> {
const memberships = await this.memberRepo.find({
where: { user: { id: userId } },
relations: ['repo'],
});
const repoIds = memberships.map((m) => m.repo.id);
if (repoIds.length === 0) return [];
const repos = await this.repoRepo.find({
where: { id: In(repoIds) },
relations: ['members', 'members.user'],
order: { updatedAt: 'DESC' },
});
return repos.map((repo) => this.toResponse(repo));
}
// 저장소 단건 — 접근 권한 확인(멤버이거나 공개)
async findOneForUser(
slugName: string,
userId: string,
): Promise<RepoResponse> {
const repo = await this.getEntityOrThrow(slugName);
const isMember = repo.members.some((m) => m.user.id === userId);
if (!isMember && repo.visibility !== 'public') {
throw new ForbiddenException();
}
return this.toResponse(repo);
}
// 저장소 생성 — 생성자를 소유자(admin)로 등록
async create(dto: CreateRepoDto, userId: string): Promise<RepoResponse> {
const existing = await this.repoRepo.findOne({
where: { slugName: dto.slug },
});
if (existing) {
throw new BusinessException(
'BIZ_001',
'이미 사용 중인 저장소 이름입니다.',
HttpStatus.CONFLICT,
);
}
const user = await this.userService.findById(userId);
if (!user) {
throw new NotFoundException();
}
const repo = this.repoRepo.create({
slugName: dto.slug,
owner: DEFAULT_OWNER,
name: dto.name,
description: dto.description ?? null,
visibility: dto.visibility,
branch: dto.branch?.trim() || 'main',
});
const saved = await this.repoRepo.save(repo);
const owner = this.memberRepo.create({
repo: saved,
user,
roleType: 'admin',
isOwner: true,
subRole: '리드',
});
await this.memberRepo.save(owner);
return this.toResponse(await this.getEntityOrThrow(saved.slugName));
}
// 저장소 수정 — admin 권한 필요
async update(
slugName: string,
userId: string,
dto: UpdateRepoDto,
): Promise<RepoResponse> {
const repo = await this.getEntityOrThrow(slugName);
await this.assertAdmin(repo.id, userId);
if (dto.name !== undefined) repo.name = dto.name;
if (dto.description !== undefined) repo.description = dto.description;
if (dto.visibility !== undefined) repo.visibility = dto.visibility;
if (dto.branch !== undefined && dto.branch.trim())
repo.branch = dto.branch.trim();
await this.repoRepo.save(repo);
return this.toResponse(await this.getEntityOrThrow(slugName));
}
// 저장소 삭제 — 소유자만 가능
async remove(slugName: string, userId: string): Promise<void> {
const repo = await this.getEntityOrThrow(slugName);
const membership = await this.memberRepo.findOne({
where: { repo: { id: repo.id }, user: { id: userId } },
});
if (!membership?.isOwner) {
throw new ForbiddenException();
}
await this.repoRepo.remove(repo);
}
// --- 내부 헬퍼 ---
// slugName 으로 저장소(+멤버) 로딩, 없으면 404
private async getEntityOrThrow(slugName: string): Promise<Repo> {
const repo = await this.repoRepo.findOne({
where: { slugName },
relations: ['members', 'members.user'],
});
if (!repo) {
throw new NotFoundException();
}
return repo;
}
// admin 권한 확인 (아니면 403)
private async assertAdmin(repoId: string, userId: string): Promise<void> {
const membership = await this.memberRepo.findOne({
where: { repo: { id: repoId }, user: { id: userId } },
});
if (!membership || membership.roleType !== 'admin') {
throw new ForbiddenException();
}
}
// 엔티티 → 응답 매핑
private toResponse(repo: Repo): RepoResponse {
const members = (repo.members ?? []).map((m) =>
UserService.toPublic(m.user),
);
return {
id: repo.slugName,
name: repo.name,
owner: repo.owner,
slug: `${repo.owner}/${repo.slugName}`,
desc: repo.description,
visibility: repo.visibility,
branch: repo.branch,
members,
memberCount: members.length,
// TODO(4단계): 업무 모듈 도입 시 실제 완료/전체 수 집계
doneCount: 0,
totalCount: 0,
updatedAt: repo.updatedAt.toISOString(),
};
}
}
@@ -0,0 +1,37 @@
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
// 사용자 엔티티 — 인증/계정의 단일 진실 공급원
@Entity('users')
export class User {
// 외부 노출 식별자 (UUID)
@PrimaryGeneratedColumn('uuid')
id!: string;
// 로그인 이메일 (유일)
@Column({ unique: true })
email!: string;
// 비밀번호 해시 — 평문 저장 금지. 조회 시 기본 제외(select: false)
@Column({ name: 'password_hash', select: false })
passwordHash!: string;
// 표시 이름
@Column()
name!: string;
// 전사 직무(예: '콘텐츠 기획') — 선택값
@Column({ type: 'varchar', nullable: true })
role!: string | null;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserService } from './user.service';
import { User } from './entities/user.entity';
// 사용자 데이터 계층 모듈 — UserService 를 다른 모듈(Auth 등)에 제공
@Module({
imports: [TypeOrmModule.forFeature([User])],
providers: [UserService],
exports: [UserService],
})
export class UserModule {}
+66
View File
@@ -0,0 +1,66 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
// 외부 노출용 사용자 형태 (passwordHash 등 민감정보 제외)
export interface PublicUser {
id: string;
email: string;
name: string;
role: string | null;
}
// 사용자 데이터 접근 계층 — 인증 모듈이 주입하여 사용
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
) {}
// id 단건 조회 (없으면 null)
findById(id: string): Promise<User | null> {
return this.userRepository.findOne({ where: { id } });
}
// 이메일로 조회 (가입 중복 체크용)
findByEmail(email: string): Promise<User | null> {
return this.userRepository.findOne({ where: { email } });
}
// 로그인 검증용 — passwordHash 를 명시적으로 포함하여 조회
findByEmailWithPassword(email: string): Promise<User | null> {
return this.userRepository
.createQueryBuilder('user')
.addSelect('user.passwordHash')
.where('user.email = :email', { email })
.getOne();
}
// 신규 사용자 생성 (비밀번호는 해시된 상태로 전달받음)
create(payload: {
email: string;
passwordHash: string;
name: string;
role?: string | null;
}): Promise<User> {
const user = this.userRepository.create({
email: payload.email,
passwordHash: payload.passwordHash,
name: payload.name,
role: payload.role ?? null,
});
return this.userRepository.save(user);
}
// 엔티티 → 외부 노출용 형태로 변환 (민감정보 제거)
static toPublic(user: User): PublicUser {
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
};
}
}
@@ -0,0 +1,20 @@
import { utilities as nestWinstonUtilities } from 'nest-winston';
import * as winston from 'winston';
// 표준 로거 설정 — console.log 대체용
// 운영 환경에서는 파일 로테이션 또는 외부 로그 수집기(예: Loki, ELK) 추가 권장
export const winstonConfig: winston.LoggerOptions = {
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.timestamp(),
winston.format.ms(),
nestWinstonUtilities.format.nestLike('App', {
colors: process.env.NODE_ENV !== 'production',
prettyPrint: true,
}),
),
}),
],
};
+13
View File
@@ -0,0 +1,13 @@
// 공통 포맷터 — 부수효과 없는 순수 함수만 작성
/**
* Date 또는 ISO 문자열을 'YYYY-MM-DD' 형식으로 변환
*/
export function formatDate(input: Date | string): string {
const date = typeof input === 'string' ? new Date(input) : input;
if (Number.isNaN(date.getTime())) return '';
const yyyy = date.getFullYear();
const mm = String(date.getMonth() + 1).padStart(2, '0');
const dd = String(date.getDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
}
+25
View File
@@ -0,0 +1,25 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication<App>;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
});
+9
View File
@@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}
+33
View File
@@ -0,0 +1,33 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"resolvePackageJsonExports": true,
"esModuleInterop": true,
"isolatedModules": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strict": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"watchOptions": {
"watchFile": "priorityPollingInterval",
"watchDirectory": "dynamicPriorityPolling",
"fallbackPolling": "dynamicPriority"
}
}
+35
View File
@@ -0,0 +1,35 @@
# 실행 / 배포 명령어
> Docker Compose 기반 로컬·운영 오케스트레이션 명령어 모음.
> `.env.development` / `.env.production` 는 커밋된 `.env.*.example` 를 복사해 생성한다.
> (`.gitignore` 는 `.env.*` 를 무시하되 `.env.*.example` 만 추적한다.)
## 개발 환경
```bash
# 코드만 수정했을 때 (의존성 변경 없음)
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d
# 의존성 변경 시에만 --build
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d --build
# 로그 확인 (예: backend)
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development logs -f backend
```
## 운영 환경
```bash
# 운영 환경 배포
sudo docker compose --env-file .env.production up -d --build
# 로그 확인
sudo docker compose --env-file .env.production logs -f backend
```
## 외부 노출 (선택)
```bash
# ngrok 으로 백엔드 포트(3000) 임시 노출 — <YOUR_DOMAIN> 을 본인 도메인으로 교체
ngrok http --domain=<YOUR_DOMAIN>.ngrok.app 3000
```
+45
View File
@@ -0,0 +1,45 @@
services:
frontend:
build:
target: build-stage
command: npm run dev -- --host 0.0.0.0 --port 80
ports:
- "${FRONTEND_PORT}:80"
environment:
- CHOKIDAR_USEPOLLING=true
- WATCHPACK_POLLING=true
volumes:
- ./${FRONTEND_DIR}:/app
- /app/node_modules
# dev에서는 리버스 프록시 미사용 — base의 networks 목록을 덮어씀
networks: !override
- app-network
backend:
build:
target: builder
command: npm run start:dev
ports:
- "${BACKEND_PORT}:${BACKEND_PORT}"
- "${WEBSOCKET_PORT}:${WEBSOCKET_PORT}"
environment:
- CHOKIDAR_USEPOLLING=true
- WATCHPACK_POLLING=true
volumes:
- ./${BACKEND_DIR}:/app
- /app/node_modules
# dev에서는 리버스 프록시 미사용 — base의 networks 목록을 덮어씀
networks: !override
- app-network
db:
ports:
- "${DB_PORT}:5432"
redis:
ports:
- "${REDIS_PORT}:6379"
# base의 proxy 외부 네트워크 의존을 dev에서 제거
networks:
proxy: !reset null
+145
View File
@@ -0,0 +1,145 @@
services:
# ----------------------------------------
# 1. Frontend (Nginx 정적 호스팅)
# ----------------------------------------
frontend:
container_name: ${PROJECT_NAME}-frontend
build:
context: ./${FRONTEND_DIR}
dockerfile: Dockerfile
args:
- BUILD_MODE=${BUILD_MODE}
- VITE_API_BASE_URL=${BACKEND_API_URL}
expose:
- "80"
environment:
- NODE_ENV=${APP_ENV}
restart: unless-stopped
networks:
- app-network
- proxy
depends_on:
- backend
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:80/ || exit 1"]
interval: 30s
timeout: 5s
retries: 5
start_period: 10s
deploy:
resources:
limits:
cpus: '0.5'
memory: 512m
# ----------------------------------------
# 2. Backend (HTTP API & WebSocket 통합)
# ----------------------------------------
backend:
container_name: ${PROJECT_NAME}-backend
build:
context: ./${BACKEND_DIR}
dockerfile: Dockerfile
expose:
- "${BACKEND_PORT}"
- "${WEBSOCKET_PORT}"
env_file:
- ./${BACKEND_DIR}/.env.${APP_ENV}
environment:
- NODE_ENV=${APP_ENV}
- PORT=${BACKEND_PORT}
- WEBSOCKET_PORT=${WEBSOCKET_PORT}
- CORS_ORIGIN=${CORS_ORIGIN}
- DB_HOST=db
- DB_PORT=5432
- DB_USER=${DB_USER}
- DB_PASSWORD=${DB_PASSWORD}
- DB_NAME=${DB_NAME}
- REDIS_HOST=redis
- REDIS_PORT=6379
- REDIS_PASSWORD=${REDIS_PASSWORD}
restart: unless-stopped
networks:
- app-network
- proxy
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:${BACKEND_PORT}/ || exit 1"]
interval: 30s
timeout: 5s
retries: 5
start_period: 30s
deploy:
resources:
limits:
cpus: '1.0'
memory: 1g
# ----------------------------------------
# 3. Database (PostgreSQL) — 내부 네트워크 전용
# ----------------------------------------
db:
container_name: ${PROJECT_NAME}-db
image: ${DB_IMAGE}
expose:
- "5432"
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_NAME}
volumes:
- ${DB_DATA_DIR}:/var/lib/postgresql/data
restart: always
networks:
- app-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
deploy:
resources:
limits:
cpus: '0.5'
memory: 512m
labels:
- "com.centurylinklabs.watchtower.enable=true"
# ----------------------------------------
# 4. Redis — 내부 네트워크 전용
# ----------------------------------------
redis:
container_name: ${PROJECT_NAME}-redis
image: ${REDIS_IMAGE}
command: redis-server --requirepass ${REDIS_PASSWORD}
expose:
- "6379"
volumes:
- ${REDIS_DATA_DIR}:/data
restart: always
networks:
- app-network
healthcheck:
test: ["CMD-SHELL", "redis-cli -a ${REDIS_PASSWORD} ping | grep PONG"]
interval: 10s
timeout: 5s
retries: 5
start_period: 5s
deploy:
resources:
limits:
cpus: '0.25'
memory: 256m
labels:
- "com.centurylinklabs.watchtower.enable=true"
networks:
app-network:
driver: bridge
proxy:
external: true
+231
View File
@@ -0,0 +1,231 @@
# Relay API 계약서 (SSOT)
> 프론트(`frontend/src/mock/relay.mock.ts`) 인터페이스를 기준으로 도출한 **백엔드 ↔ 프론트 공통 계약**.
> 백엔드 엔티티/DTO와 프론트 타입은 이 문서를 단일 진실 공급원으로 삼아 양쪽이 동일하게 구현한다.
> 모든 응답은 루트 `CLAUDE.md` 의 표준 응답 래퍼(`{ success, data }` / `{ success, error }`)로 감싸지며,
> 아래 표의 "응답"은 **언래핑된 `data` 본문**만 기술한다.
## 0. 공통 규약
### 인증 / 토큰
- **이메일 + JWT만 1단계 구현.** 소셜 로그인(Google/Kakao)·이메일 인증은 8단계로 보류.
- Access / Refresh 토큰 **모두 HttpOnly·Secure 쿠키**로 전달 (웹 `localStorage` 금지).
- Access: 짧은 만료(15분), 쿠키명 `access_token`
- Refresh: 긴 만료(14일), 쿠키명 `refresh_token`, 경로 `/api/auth`
- 프론트 axios 는 `withCredentials: true` (이미 적용됨) — 별도 Authorization 헤더 불필요.
- 보호 라우트는 `JwtAuthGuard`. 미인증 시 401 → `AUTH_001`.
### ID 전략
| 대상 | 타입 | 비고 |
|---|---|---|
| User | `uuid` | 외부 노출 식별자 |
| Repo | `uuid` + `slug` | URL 은 `owner/name` slug, 라우팅 segment 는 `slug` 마지막 토큰 |
| Task | `number` | **저장소 내 순번**(GitHub 이슈처럼 `#9`). 전역 PK 는 별도 uuid |
| 그 외 | `uuid` | Comment, Attachment 등 |
### 파생 필드(저장하지 않고 계산)
다음은 **백엔드가 원시값(타임스탬프/카운트)만 내려주고 프론트 `shared/utils/format.ts` 에서 계산**한다.
- `updatedAgo`("2시간 전 업데이트"), `dueLabel`("D-3", "2일 지남", "6월 9일 완료"), `dday`, `ddayLevel`
- `progressPct`, `progressLevel`, `progressLabel`, `doneCount`/`totalCount`(서버 카운트 응답은 허용)
- 아바타 `initial`/`color` — 서버는 `name` 만, color 는 userId 해시로 프론트 산출(또는 서버 저장)
따라서 API 응답의 날짜 필드는 모두 **ISO 8601 문자열**(`dueDate`, `updatedAt`, `createdAt` 등)로 내린다.
### 비즈니스 에러 (사용자 노출 문구)
도메인 검증 실패처럼 **사용자에게 구체 문구를 보여줘야 하는 경우** `BusinessException(code, message, status)`
(`backend/src/common/exceptions/business.exception.ts`)를 던진다. 전역 필터가 `{ code, message }`
status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프론트는 `BIZ_001` 코드일 때 서버 `message` 를 그대로 노출한다.
예: 로그인 실패 → `BusinessException('BIZ_001', '이메일 또는 비밀번호가 올바르지 않습니다.', 401)`.
### 페이지네이션
목록 API 는 오프셋 기반: 쿼리 `?page=1&size=20`, 응답 `{ items: [...], total, page, size }`.
(1단계에서는 단순 배열 허용, 7단계에서 페이지네이션으로 통일)
---
## 1. 인증 (`/api/auth`) — **1단계**
| 메서드 | 경로 | 요청 | 응답 | 비고 |
|---|---|---|---|---|
| POST | `/auth/signup` | `{ email, password, name }` | `{ user }` | 가입 후 자동 로그인(쿠키 set) |
| POST | `/auth/login` | `{ email, password, keepLogin? }` | `{ user }` | access/refresh 쿠키 set |
| POST | `/auth/logout` | — | `null` | 쿠키 clear |
| POST | `/auth/refresh` | — (refresh 쿠키) | `null` | access 쿠키 재발급 |
| GET | `/auth/me` | — | `User` | 부트스트랩/가드용. 미인증 401 |
`User` 응답 형태:
```jsonc
{
"id": "uuid",
"email": "sykim@acme.co",
"name": "김서연",
"role": "콘텐츠 기획" // 전사 직무(선택)
}
```
> 프론트 매핑: `auth.store.ts`(신규) + `useAuth` composable. 라우터 가드 TODO 를
> `me` 부트스트랩 + 401→`/login` 리다이렉트로 교체. `LoginPage`/`SignupPage` 의 `router.push` 를 실제 호출로.
---
## 2. 저장소 (`/api/repos`) — **2단계**
| 메서드 | 경로 | 요청 | 응답 |
|---|---|---|---|
| GET | `/repos` | `?page&size` | `Repo[]`(요약) — 내가 멤버인 저장소 |
| POST | `/repos` | `CreateRepoDto` | `Repo` |
| GET | `/repos/:repoId` | — | `Repo`(상세) |
| PATCH | `/repos/:repoId` | `UpdateRepoDto` | `Repo` |
| DELETE | `/repos/:repoId` | — | `null` |
`CreateRepoDto`: `{ name, desc, visibility('private'|'public'), branch? }`
`Repo` 응답(원시값 중심):
```jsonc
{
"id": "q3-launch-campaign", // slug 마지막 segment = 라우팅 id
"name": "3분기 신제품 런칭 캠페인",
"owner": "marketing-team",
"slug": "marketing-team/q3-launch-campaign",
"desc": "...",
"visibility": "private",
"branch": "main",
"members": [ /* User , N + memberCount */ ],
"memberCount": 5,
"doneCount": 8,
"totalCount": 12,
"updatedAt": "2026-06-16T11:00:00Z"
}
```
> `moreCount`, `progressPct/Level/Label`, `updatedAgo` 는 프론트 계산.
> 매핑 페이지: RepoListPage, RepoCreatePage, RepoDetailPage(헤더), RepoSettingsPage.
---
## 3. 멤버 (`/api/repos/:repoId/members`) — **3단계**
| 메서드 | 경로 | 요청 | 응답 | 권한 |
|---|---|---|---|---|
| GET | `.../members` | — | `RepoMember[]` | member+ |
| POST | `.../members` | `{ email, roleType, subRole? }` | `RepoMember` | admin |
| PATCH | `.../members/:userId` | `{ roleType?, subRole? }` | `RepoMember` | admin |
| DELETE | `.../members/:userId` | — | `null` | admin (owner 제거 불가) |
`RepoMember`: `{ user: User, email, subRole, taskCount, roleType('admin'|'member'), owner }`
(`isYou` 는 프론트가 현재 사용자와 비교해 산출)
> 매핑 페이지: RepoMembersPage(초대 모달, 역할 변경, 제거).
---
## 4. 업무 (`/api/repos/:repoId/tasks`) — **4단계**
| 메서드 | 경로 | 요청 | 응답 |
|---|---|---|---|
| GET | `.../tasks` | `?status&assignee` | `RepoTask[]` |
| POST | `.../tasks` | `CreateTaskDto` | `TaskDetail` |
| GET | `.../tasks/:taskId` | — | `TaskDetail` |
| PATCH | `.../tasks/:taskId` | `UpdateTaskDto` | `TaskDetail` |
| DELETE | `.../tasks/:taskId` | — | `null` |
| POST | `.../tasks/:taskId/status` | `{ status }` | `TaskDetail` |
상태(`TaskStatus`): `todo | prog | review | done | changes`. 전이 규칙(상태 머신):
```
todo ─시작→ prog ─승인요청→ review ─승인→ done
▲ └─수정요청→ changes ─재요청→ review
└──────────────────────────────────┘
```
- `POST .../status` 는 일반 상태 변경.
- **승인 워크플로우**(5단계와 함께): 담당자 "승인 요청"(prog→review), 지시자 "승인"(review→done) / "수정 요청"(review→changes).
`CreateTaskDto`: `{ title, content?: string[], assigneeIds: uuid[], dueDate?: ISO, checklist?: {text}[] }`
`RepoTask`(목록 행): `{ id, title, status, dueDate, checklist: [done,total], commentCount, assignees: User[] }`
`TaskDetail`: mock 의 `TaskDetail` 인터페이스와 동일 — 단 라벨류는 원시값으로:
`{ id, repoId, repoName, title, status, content[], checklist[], comments[], activities[], assignees[], issuer, dueDate, files[], approval?, createdAt }`
> 매핑 페이지: RepoDetailPage(목록), TaskCreatePage, TaskDetailPage(지시자), TaskAssigneePage(담당자, state 분기).
---
## 5. 업무 상세 부속 — **5단계**
### 체크리스트
| 메서드 | 경로 | 요청 | 응답 |
|---|---|---|---|
| POST | `.../tasks/:taskId/checklist` | `{ text }` | `ChecklistItem` |
| PATCH | `.../checklist/:itemId` | `{ text?, done? }` | `ChecklistItem` |
| DELETE | `.../checklist/:itemId` | — | `null` |
### 댓글 / 답글
| 메서드 | 경로 | 요청 | 응답 |
|---|---|---|---|
| POST | `.../tasks/:taskId/comments` | `{ text, fileId? }` | `Comment` |
| POST | `.../comments/:commentId/replies` | `{ text }` | `Reply` |
### 파일 첨부
| 메서드 | 경로 | 요청 | 응답 |
|---|---|---|---|
| POST | `.../tasks/:taskId/files` | `multipart/form-data` | `Attachment` |
| DELETE | `.../files/:fileId` | — | `null` |
> **결정 필요**: 파일 저장소(로컬 볼륨 vs S3/MinIO). `Attachment`: `{ id, name, size, kind('pdf'|'zip'|'doc'|'img'), url }`.
### 승인 워크플로우
| 메서드 | 경로 | 요청 | 응답 |
|---|---|---|---|
| POST | `.../tasks/:taskId/approval/request` | `{ note }` | `TaskDetail` |
| POST | `.../tasks/:taskId/approval/approve` | — | `TaskDetail` |
| POST | `.../tasks/:taskId/approval/changes` | `{ note }` | `TaskDetail` |
---
## 6. 활동 피드 — **6단계**
활동은 **2~5단계 행위에서 이벤트로 자동 적재**(쓰기 API 없음, 읽기만).
| 메서드 | 경로 | 응답 |
|---|---|---|
| GET | `/repos/:repoId/activity` | `RepoActivityDay[]`(날짜 그룹) |
| (업무 활동) | `TaskDetail.activities` 에 포함 | `Activity[]` |
`RepoActivityItem`: `{ tone, icon, actor: User, payload, createdAt }`
> 서버는 이벤트 타입 + 행위자 + 대상만 저장하고, **표시 HTML 문구는 프론트에서 조립**(현재 mock 은 html 통문자열 — 연동 시 구조화 권장). 매핑: RepoActivityPage, TaskDetail 활동 탭.
---
## 7. 내 업무 (`/api/tasks`) — **7단계**
| 메서드 | 경로 | 요청 | 응답 |
|---|---|---|---|
| GET | `/tasks/assigned` | — | `MyTaskGroup[]`(상태 그룹) — 내가 담당 |
| GET | `/tasks/issued` | — | `MyTaskGroup[]` — 내가 지시 |
저장소 횡단 집계. `MyTaskGroup`/`MyTaskRow` 는 mock 인터페이스와 동일하되 라벨류 제외, 원시값(`dueDate`, `status`) 기반.
> 매핑 페이지: MyTasksPage(담당/지시 탭, 상태 그룹). `countActive` 는 프론트 유지.
---
## 8. 마무리(선택) — **8단계**
소셜 로그인(Google/Kakao OAuth), 이메일 인증, 알림, AI 에이전트 패널, Redis 캐싱, 전체 페이지네이션 통일.
---
## 백엔드 모듈 매핑 (구현 순서)
```
src/modules/
auth/ (1) — User 엔티티, JWT 전략, Guard, 쿠키
repo/ (2) — Repo 엔티티
member/ (3) — RepoMember 조인 엔티티 (repo 모듈에 흡수 가능)
task/ (4,5) — Task / ChecklistItem / Comment / Reply / Attachment
activity/ (6) — Activity 이벤트 (다른 모듈에서 emit)
my-tasks/ (7) — 집계 전용 (task 모듈에 흡수 가능)
```
+128
View File
@@ -0,0 +1,128 @@
# Relay 백엔드 연동 진행 현황
> 프론트 UI(목업)를 NestJS 백엔드에 단계적으로 연동하는 작업의 진행 기록.
> 계약/스키마 상세는 [`api-contract.md`](./api-contract.md) 참조.
> 최종 갱신: 2026-06-16 · 현재 위치: **2단계(저장소) 완료, 3단계(멤버) 대기**
---
## 1. 작업 전략
- **도메인별 수직 슬라이스**: 한 도메인을 `엔티티 → DTO → service → controller → Swagger → 프론트 composable → store → 페이지 목업 교체`까지 끝내고 다음 도메인으로 이동. (수평 분할 아님)
- 프론트는 백엔드 원시 데이터(타임스탬프/카운트)를 받아 **표시용 파생값(상대시간·진행률·아바타 색상 등)은 `shared/utils/format.ts`에서 계산**.
- 모든 응답은 표준 래퍼 `{ success, data }` / `{ success, error }`. 에러 코드 맵은 `CLAUDE.md` SSOT.
- 인증은 **이메일+JWT만 먼저**(소셜 로그인·이메일 인증은 8단계로 보류). access/refresh 모두 HttpOnly·Secure 쿠키.
### 단계 순서
```
0. 계약 정렬 ✅ → 1. 인증 ✅ → 2. 저장소 ✅
→ 3. 멤버 → 4. 업무 → 5. 업무 상세 부속 → 6. 활동 피드 → 7. 내 업무 → 8. 마무리
```
---
## 2. 완료된 단계
### 0단계 — 계약 정렬 ✅
- **`docs/api-contract.md`**: 7개 도메인 전체 엔드포인트(요청/응답/권한), ID 전략, 파생 필드 규칙, 상태 머신, 비즈니스 에러 메커니즘을 단일 진실 공급원으로 정의.
- **비즈니스 에러 메커니즘**: `backend/src/common/exceptions/business.exception.ts``BusinessException(code, message, status)`.
- 전역 필터(`http-exception.filter.ts`)가 `{ code, message }` 를 status 매핑보다 **우선** 처리 → 정확한 HTTP 상태를 유지하면서 사용자에게 구체 문구 노출.
- 프론트 `useApi` 인터셉터는 `BIZ_001` 코드일 때 서버 `message` 를 우선 표시.
- 기존 status→코드 매핑 구조는 그대로 유지(덧댄 우선 분기). `BusinessException` 을 쓰지 않으면 기존과 동일 동작.
### 1단계 — 인증 (이메일+JWT) ✅
**백엔드** `backend/src/modules/auth/`, `modules/user/`
- `User` 엔티티(UUID, email unique, `password_hash` select:false, name, role nullable).
- `UserService`(Repository 기반): `findById`/`findByEmail`/`findByEmailWithPassword`/`create`/`toPublic`.
- `AuthService`: 가입(중복 검사), 로그인 검증(bcryptjs), access/refresh 토큰 발급.
- `AuthController`: `POST /auth/signup`·`/login`·`/logout`·`/refresh`(refresh 가드), `GET /auth/me`(access 가드). HttpOnly 쿠키 발급/제거.
- JWT access/refresh 전략(쿠키에서 추출) + 가드 + `@CurrentUser()` 데코레이터.
- `main.ts``cookie-parser`. JWT 시크릿은 `backend/.env.{env}`(커밋된 `.example` 포함).
**프론트** `frontend/src/`
- `types/auth.ts`, `composables/useAuth.ts`, `stores/auth.store.ts`(bootstrap/login/signup/logout).
- `composables/useApi.ts`: 401→`/auth/refresh` 자동 1회 재시도, `silent` 플래그(부트스트랩 무알림), BIZ_001 서버 메시지 우선.
- 라우터 가드: 최초 진입 시 `/auth/me` 세션 복원 + 미인증 리다이렉트(redirect 쿼리 보존).
- LoginPage/SignupPage 실제 연동(소셜 버튼은 안내, 가입 즉시 자동 로그인), AppShell 아바타=실사용자 + 로그아웃 드롭다운.
**로그인 실패 문구**: `BusinessException('BIZ_001', '이메일 또는 비밀번호가 올바르지 않습니다.', 401)` / 중복 가입: 동일 패턴 409.
### 2단계 — 저장소(Repo) CRUD ✅
**백엔드** `backend/src/modules/repo/`
- `Repo` 엔티티: `slugName`(영문, 라우팅 id, unique), `owner`('marketing-team' 고정 상수), `name`(한글 표시제목), `description`, `visibility`, `branch`.
- `RepoMember` 엔티티: repo↔user 조인, `roleType`(admin/member), `isOwner`, `subRole`, `@Unique([repo, user])`.
- `RepoService`: 내 멤버십 저장소 목록 / 단건(멤버·공개 접근 체크) / 생성(생성자를 owner·admin 등록) / 수정(admin) / 삭제(owner).
- `GET/POST/PATCH/DELETE /repos[/:repoId]` — 전부 `JwtAuthGuard`. 응답 `RepoResponse`(원시값; `doneCount/totalCount`=0, 4단계에서 집계).
**프론트**
- `shared/utils/format.ts`: `avatarColor`(id 해시)·`initialOf`·`relativeTimeKo`·`progressOf` 추가.
- `types/repo.ts`, `composables/useRepo.ts`, `stores/repo.store.ts`(API→기존 mock `Repo`/`User` 뷰로 매핑: 아바타 3명+`moreCount`, 진행률, 상대시간).
- 페이지 연동: **RepoListPage**(목록·로딩/빈상태), **RepoCreatePage**(생성→상세), **RepoDetailPage**(헤더·진행률만), **RepoSettingsPage**(저장·삭제·이름변경 안내).
---
## 3. 남은 단계
### 3단계 — 멤버 관리
- 백엔드: `GET/POST/PATCH/DELETE /repos/:repoId/members`(목록·초대·역할변경·제거), admin 권한 가드, owner 제거 불가.
- 프론트: `useMember`/`member.store`, **RepoMembersPage**(초대 모달·역할 변경·제거) 연동.
- `RepoMember` 엔티티는 2단계에서 이미 존재 → 데이터 계층은 일부 재사용.
### 4단계 — 업무(Task) CRUD + 상태 전이
- 백엔드: `Task`(+`ChecklistItem`) 엔티티, `GET/POST/PATCH/DELETE /repos/:repoId/tasks[/:taskId]`, `POST .../status`. 상태 머신 todo→prog→review→done/changes.
- 저장소 `doneCount/totalCount` 실제 집계로 교체(현재 0 하드코딩).
- 프론트: **RepoDetailPage 업무 목록**, **TaskCreatePage**, **TaskDetailPage**(지시자), **TaskAssigneePage**(담당자) 연동.
### 5단계 — 업무 상세 부속
- 체크리스트 토글, 댓글/답글, **파일 첨부**, 승인 워크플로우(승인 요청/승인/수정 요청).
- **결정 필요**: 파일 저장소(로컬 볼륨 vs S3/MinIO).
### 6단계 — 활동 피드
- 2~5단계 행위를 이벤트로 적재(쓰기 API 없음, 읽기만). `GET /repos/:repoId/activity`.
- 프론트: **RepoActivityPage**, TaskDetail 활동 탭.
- **결정 필요**: 활동 표현 — 현재 mock은 HTML 통문자열 → 구조화(타입+행위자+대상) 권장.
### 7단계 — 내 업무 집계
- `GET /tasks/assigned`·`/tasks/issued`(저장소 횡단 집계). 프론트: **MyTasksPage**(담당/지시 탭).
### 8단계 — 마무리(선택)
- 소셜 로그인(Google/Kakao), 이메일 인증, 알림, AI 에이전트 패널, Redis 캐싱, 전체 페이지네이션 통일.
---
## 4. 전환기 한계 (현재 시점)
- **RepoMembersPage·RepoActivityPage** 는 아직 mock `findRepo` 사용 → 실제 생성한 저장소의 **멤버/활동 탭은 "찾을 수 없음"** 표시(각각 3·6단계에서 해소).
- **RepoDetail 업무 목록**도 mock(4단계). 업무 모듈이 없어 모든 저장소 **진행률은 `0% / 시작 전`**.
- `src/mock/relay.mock.ts` 는 아직 타입 정의(`Repo`/`User`/`TaskStatus` 등) + 미연동 화면 데이터 공급원으로 사용 중. 해당 도메인이 연동되면 데이터는 제거하고 타입만 `src/types/` 로 이전.
---
## 5. 알아둘 사항 / 결정 기록
- **엔티티 순환참조**: 서로 참조하는 관계(예: `Repo``RepoMember`)의 **단일 클래스 관계 속성에는 TypeORM `Relation<>` 래퍼**를 사용한다. 안 그러면 SWC 환경에서 `Cannot access 'X' before initialization` 크래시. → 4·5단계(Task↔Repo, Comment↔Task 등)에도 동일 적용.
- **bcrypt → bcryptjs**: Docker 베이스가 `node:20-alpine`(빌드 도구 없음)이라 네이티브 모듈 `bcrypt` 대신 순수 JS `bcryptjs` 사용.
- **dev 도커 익명 볼륨**: `/app/node_modules` 가 익명 볼륨이라 **의존성 변경 시 `up -d --build -V`(익명 볼륨 갱신)** 필요. `--build` 만으로는 기존 node_modules 볼륨이 재사용되어 새 패키지가 반영되지 않음.
- **owner 고정**: 조직 모델이 없어 `owner='marketing-team'` 상수. 조직 도입 시 교체.
- **ID 전략**: User/Repo PK=UUID, Repo 라우팅 식별자=`slugName`, Task=저장소 내 순번(#9, 4단계).
---
## 6. 실행 / 검증
```bash
# 개발 환경 기동 (의존성 변경 시 --build -V)
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d --build -V
# 백엔드 로그
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development logs -f backend
```
- 백엔드: `npm run build` / `npm run lint` (현재 0 error)
- 프론트: `npm run type-check` / `npm run lint` / `npm run build-only` (현재 0 error)
- dev `synchronize=true``users`·`repos`·`repo_members` 테이블 자동 생성. DB가 비어 있으므로 **회원가입 후 로그인**.
- 포트: 프론트 5273, 백엔드 3100(`/api`), Swagger `http://localhost:3100/api-docs`.
+12
View File
@@ -0,0 +1,12 @@
# ==========================================
# Flutter 환경변수 (flutter_dotenv) — pubspec assets 에 .env 등록됨
# 사용법: cp .env.example .env
#
# ⚠️ 모바일 앱은 docker-compose 대상이 아니므로 루트 .env 의 위임을 받지 않는다.
# 아래 값은 항상 이 파일에서 직접 관리한다.
# 빌드 타임 분리가 필요하면 --dart-define 또는 .env.{flavor} 도입 고려.
# ==========================================
# 백엔드 API 베이스 URL (백엔드 글로벌 prefix /api 포함)
# 안드로이드 에뮬레이터에서 호스트 접근 시: http://10.0.2.2:3000/api
API_URL=http://localhost:3000/api
+54
View File
@@ -0,0 +1,54 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
.env.development
.env.production
+45
View File
@@ -0,0 +1,45 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "ff37bef603469fb030f2b72995ab929ccfc227f0"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- platform: android
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- platform: ios
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- platform: linux
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- platform: macos
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- platform: web
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- platform: windows
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
+17
View File
@@ -0,0 +1,17 @@
# tma_app
A new Flutter project.
## Getting Started
This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project:
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.
+28
View File
@@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
+14
View File
@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
+44
View File
@@ -0,0 +1,44 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.example.tma_app"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.tma_app"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter {
source = "../.."
}
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
@@ -0,0 +1,45 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="tma_app"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -0,0 +1,5 @@
package com.example.tma_app
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+24
View File
@@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+2
View File
@@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
+26
View File
@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")
+34
View File
@@ -0,0 +1,34 @@
**/dgph
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/
**/DerivedData/
Icon?
**/Pods/
**/.symlinks/
profile
xcuserdata
**/.generated/
Flutter/App.framework
Flutter/Flutter.framework
Flutter/Flutter.podspec
Flutter/Generated.xcconfig
Flutter/ephemeral/
Flutter/app.flx
Flutter/app.zip
Flutter/flutter_assets/
Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
</dict>
</plist>
+1
View File
@@ -0,0 +1 @@
#include "Generated.xcconfig"

Some files were not shown because too many files have changed in this diff Show More