first commit
This commit is contained in:
@@ -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 분리
|
||||
@@ -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/`) 적용 고려
|
||||
@@ -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
@@ -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/
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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>
|
||||
<!--[](https://opencollective.com/nest#backer)
|
||||
[](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).
|
||||
@@ -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" }],
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true,
|
||||
"builder": "swc",
|
||||
"typeCheck": true
|
||||
}
|
||||
}
|
||||
Generated
+13713
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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!');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
],
|
||||
};
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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!');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 모듈에 흡수 가능)
|
||||
```
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
#include "Generated.xcconfig"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user