v0.1.43 - 플래너 라벨과 가이드 동작 정리
This commit is contained in:
8
.env.example
Normal file
8
.env.example
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
POSTGRES_DB=ten_minute_planner
|
||||||
|
POSTGRES_USER=replace-with-private-db-user
|
||||||
|
POSTGRES_PASSWORD=replace-with-private-db-password
|
||||||
|
DATABASE_URL=postgresql://replace-with-private-db-user:replace-with-private-db-password@postgres:5432/ten_minute_planner
|
||||||
|
ADMIN_ACCOUNT_ID=replace-with-private-admin-id
|
||||||
|
ADMIN_ACCOUNT_PASSWORD=replace-with-private-admin-password
|
||||||
|
ADMIN_ACCOUNT_EMAIL=admin@example.com
|
||||||
|
ADMIN_ACCOUNT_NICKNAME=Planner Admin
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -3,5 +3,7 @@ backend/node_modules/
|
|||||||
dist/
|
dist/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.log
|
*.log
|
||||||
|
.env
|
||||||
|
.env.dev
|
||||||
backend/.env
|
backend/.env
|
||||||
backend/data/
|
backend/data/
|
||||||
|
|||||||
18
HANDOFF.md
18
HANDOFF.md
@@ -203,15 +203,27 @@
|
|||||||
- 관리자 전용 `ADMIN` 메뉴와 기본 대시보드가 추가되었다. 현재는 사용자 수, 최근 접속, 문서 수, 목표 수, 계정별 최종 접속일을 읽기 전용으로 확인할 수 있다.
|
- 관리자 전용 `ADMIN` 메뉴와 기본 대시보드가 추가되었다. 현재는 사용자 수, 최근 접속, 문서 수, 목표 수, 계정별 최종 접속일을 읽기 전용으로 확인할 수 있다.
|
||||||
- `users` 테이블에 `login_id`, `role`, `last_login_at` 컬럼이 추가되었다.
|
- `users` 테이블에 `login_id`, `role`, `last_login_at` 컬럼이 추가되었다.
|
||||||
- 관리자 계정은 이제 이메일이 아니라 별도 자동 생성 계정으로 관리한다.
|
- 관리자 계정은 이제 이메일이 아니라 별도 자동 생성 계정으로 관리한다.
|
||||||
- 기본 관리자 계정은 `planner-admin / wps!vmffosj180204` 이고, 서버 시작 시 자동 생성된다.
|
- 관리자 계정은 서버 시작 시 `ADMIN_ACCOUNT_ID`, `ADMIN_ACCOUNT_PASSWORD`, `ADMIN_ACCOUNT_EMAIL`, `ADMIN_ACCOUNT_NICKNAME` 환경변수 조합으로 자동 생성된다.
|
||||||
- 관리자 판별용 환경변수는 `ADMIN_EMAILS`가 아니라 `ADMIN_ACCOUNT_ID`, `ADMIN_ACCOUNT_PASSWORD`, `ADMIN_ACCOUNT_EMAIL`, `ADMIN_ACCOUNT_NICKNAME` 조합으로 바뀌었다.
|
- 관리자 아이디와 비밀번호는 저장소 문서에 실제 값을 남기지 않고, Docker 배포 시 루트 `.env` 같은 비공개 환경변수 파일에서만 관리한다.
|
||||||
- 관리자 대시보드는 현재 읽기 전용이며, 계정 정지/삭제/강제 로그아웃 같은 실제 운영 액션은 아직 없다.
|
- 관리자 대시보드는 현재 읽기 전용이며, 계정 정지/삭제/강제 로그아웃 같은 실제 운영 액션은 아직 없다.
|
||||||
- 배포용 `docker-compose.yml`은 현재 PostgreSQL 외부 포트 `45432`, 프론트 외부 포트 `48081`, DB 계정 `zenn` 기준으로 맞춰져 있다.
|
- 배포용 `docker-compose.yml`은 현재 PostgreSQL 외부 포트 `45432`, 프론트 외부 포트 `48081` 기준이며, DB 계정/비밀번호는 루트 `.env`에서 주입한다.
|
||||||
- 설정 화면의 보조 메모 카드는 주석 처리되어 현재는 보이지 않는다.
|
- 설정 화면의 보조 메모 카드는 주석 처리되어 현재는 보이지 않는다.
|
||||||
- 비로그인 랜딩 카드는 상단 고정이 아니라 화면 중앙에 오도록 정렬을 수정했다.
|
- 비로그인 랜딩 카드는 상단 고정이 아니라 화면 중앙에 오도록 정렬을 수정했다.
|
||||||
|
- 로그인/회원가입 문구와 플레이스홀더는 일반 사용자 기준으로 정리했고, 사이드바 계정 카드의 `ADMIN/USER` 역할 텍스트는 숨겼다.
|
||||||
|
- 오른쪽 정보 패널에 `미완료 항목 이월` 버튼을 추가했다. 현재 날짜의 체크 안 된 할 일을 다음 날짜의 비어 있는 할 일 칸에 순서대로 복사한다.
|
||||||
|
- TASK 체크 버튼은 키보드 탭 이동 시 focus ring이 보이도록 개선했다. TASK 행은 클릭만으로 선택되지 않고, 포인터를 누른 채 다른 행까지 드래그했을 때부터 전역 pointer move 기준으로 여러 행을 선택한다. input에서 드래그를 시작해도 두 번째 행으로 넘어가면 기존 input 포커스를 blur 처리해 `Delete` / `Backspace`는 선택한 할 일 제목과 체크 상태를 한 번에 비우고, `Escape`는 선택만 해제하도록 했다.
|
||||||
|
- 인쇄 전용 CSS에서 COMMENT와 총 시간 영역의 폭을 고정하고 textarea overflow를 숨겨, PRINT 시 COMMENT가 우측 시간 영역과 겹치지 않도록 보정했다.
|
||||||
|
- 오른쪽 패널의 `TASK LABELS`와 `D-DAY 사용`은 한 카드 안의 두 줄 토글로 압축했다. 설명 문구는 공통 `GuideTooltip` 컴포넌트로 옮겼고, 물음표 버튼 클릭으로 열고 다시 클릭하거나 외부 클릭으로 닫는다. 각 툴팁은 `더 이상 보지 않기`로 숨길 수 있으며 SETTINGS의 `가이드 다시 보기`에서 전체 복원할 수 있다.
|
||||||
|
- 오른쪽 패널의 `READ NEXT`와 `PREV SNAPSHOT`은 별도 가로 카드가 아니라 `NEXT DAY` 카드 아래쪽에 세로로 배치했다.
|
||||||
|
- READ NEXT는 내일 첫 작업과 오늘 미처리 할 일 개수만 보여주도록 줄였고, 오늘 코멘트 반복 노출은 제거했다.
|
||||||
|
- 플래너 본문 시간 라벨은 `총 시간`에서 `FOCUSED TIME`으로 바꿨다. 인쇄 CSS에서 COMMENT/FOCUSED TIME 라벨이 잘리지 않도록 부모 overflow를 열고, COMMENT는 남는 폭을 채우며 FOCUSED TIME은 오른쪽 210px 칸에 붙도록 조정했다.
|
||||||
|
- Docker 배포용 루트 `.env`와 개발용 `.env.dev`를 분리했다. `docker-compose.yml`은 운영 `.env`, `docker-compose.dev.yml`은 기존 dev Postgres 볼륨과 맞는 `.env.dev`를 읽는다.
|
||||||
|
- `5173` 포트가 직접 `npm run dev` 없이 열려 있으면 대부분 `ten-minute-frontend-dev` 컨테이너가 떠 있는 상태다. 개발용 Docker를 끄려면 `docker compose -f docker-compose.dev.yml down`을 사용한다.
|
||||||
- 현재 환경에서는 Docker 데몬이 꺼져 있어서 `docker compose build` 실검증은 하지 못했고, 데몬 시작 후 다시 확인이 필요하다.
|
- 현재 환경에서는 Docker 데몬이 꺼져 있어서 `docker compose build` 실검증은 하지 못했고, 데몬 시작 후 다시 확인이 필요하다.
|
||||||
- 이미지 저장 기능은 추후 `print-only` 또는 별도 export 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다.
|
- 이미지 저장 기능은 추후 `print-only` 또는 별도 export 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다.
|
||||||
- Docker Compose는 프론트엔드와 백엔드를 함께 올리는 기준으로 설계하되, NAS 환경에 맞는 볼륨과 재시작 정책도 함께 고려한다.
|
- Docker Compose는 프론트엔드와 백엔드를 함께 올리는 기준으로 설계하되, NAS 환경에 맞는 볼륨과 재시작 정책도 함께 고려한다.
|
||||||
|
- 플래너 본문 라벨은 더 이상 `bg-paper` 배경으로 선을 덮지 않는다. `라벨 + 오른쪽 선` 구조로 바꿔 화면과 인쇄에서 노란 배경이 튀지 않도록 정리했다.
|
||||||
|
- 날짜에 적용되는 목표가 새로 생기면 D-DAY는 기본 표시된다. 사용자가 해당 날짜에서 직접 `D-DAY 사용`을 끈 경우에만 로컬 숨김 목록에 저장해 다시 숨긴다.
|
||||||
|
|
||||||
## 갱신 규칙
|
## 갱신 규칙
|
||||||
|
|
||||||
|
|||||||
17
README.md
17
README.md
@@ -17,12 +17,14 @@ NAS나 서버에서 처음 올리는 경우 흐름은 아래처럼 생각하면
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /volume1/docker
|
cd /volume1/docker
|
||||||
git clone https://git.sori.studio/zenn/planner.sori.studio.git
|
git clone https://git.sori.studio/zenn/planner.sori.studio.git .
|
||||||
cd planner.sori.studio
|
cd planner.sori.studio
|
||||||
|
cp .env.example .env
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
처음 한 번은 이미지 빌드 때문에 시간이 걸릴 수 있다.
|
처음 한 번은 이미지 빌드 때문에 시간이 걸릴 수 있다.
|
||||||
|
실행 전에 `.env`의 DB와 관리자 계정 환경변수는 운영자만 아는 값으로 반드시 바꾼다.
|
||||||
|
|
||||||
## 초보자용 빠른 실행
|
## 초보자용 빠른 실행
|
||||||
|
|
||||||
@@ -41,6 +43,8 @@ cd planner.sori.studio
|
|||||||
실제 동작 확인이나 NAS 상시 실행은 아래 명령으로 시작한다.
|
실제 동작 확인이나 NAS 상시 실행은 아래 명령으로 시작한다.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# .env에서 POSTGRES_*, DATABASE_URL, ADMIN_ACCOUNT_* 값을 비공개 운영 값으로 수정한다.
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -55,14 +59,9 @@ docker compose up -d --build
|
|||||||
- 프론트엔드: `http://NAS주소:48081`
|
- 프론트엔드: `http://NAS주소:48081`
|
||||||
- PostgreSQL: `NAS주소:45432`
|
- PostgreSQL: `NAS주소:45432`
|
||||||
|
|
||||||
기본 관리자 계정:
|
관리자 계정은 백엔드 시작 시 `.env`의 `ADMIN_ACCOUNT_*` 값으로 자동 생성된다.
|
||||||
|
관리자 아이디와 비밀번호는 저장소 문서에 적지 말고, 운영자만 접근 가능한 비공개 환경변수 파일에서 관리한다.
|
||||||
- 아이디: `planner-admin`
|
일반 사용자는 기존처럼 회원가입 후 이메일로 로그인하면 된다.
|
||||||
- 비밀번호: `wps!vmffosj180204`
|
|
||||||
|
|
||||||
관리자 계정은 백엔드 시작 시 자동 생성된다.
|
|
||||||
일반 사용자는 기존처럼 회원가입 후 이메일로 로그인하고,
|
|
||||||
관리자는 `planner-admin` 아이디로 로그인하면 된다.
|
|
||||||
|
|
||||||
현재 `docker-compose.yml` 기준 내부 구성:
|
현재 `docker-compose.yml` 기준 내부 구성:
|
||||||
|
|
||||||
|
|||||||
3
TODO.md
3
TODO.md
@@ -89,6 +89,8 @@
|
|||||||
- [x] UGREEN NAS 기준 `docker-compose.yml` 초안을 작성한다.
|
- [x] UGREEN NAS 기준 `docker-compose.yml` 초안을 작성한다.
|
||||||
- [x] 백엔드 기본 스캐폴딩을 추가한다.
|
- [x] 백엔드 기본 스캐폴딩을 추가한다.
|
||||||
- [x] PostgreSQL 전환 초안을 적용한다.
|
- [x] PostgreSQL 전환 초안을 적용한다.
|
||||||
|
- [x] 로그인 화면 문구와 관리자 정보 노출 지점을 일반 사용자 기준으로 정리한다.
|
||||||
|
- [x] 미완료 항목을 다음 날짜 빈칸으로 이월하는 버튼을 추가한다.
|
||||||
- [ ] 이메일 인증 플로우를 설계하고 구현한다.
|
- [ ] 이메일 인증 플로우를 설계하고 구현한다.
|
||||||
- [ ] 비밀번호 찾기 / 재설정 토큰 흐름을 추가한다.
|
- [ ] 비밀번호 찾기 / 재설정 토큰 흐름을 추가한다.
|
||||||
- [ ] 로그인 및 인증 관련 rate limit / 잠금 정책을 추가한다.
|
- [ ] 로그인 및 인증 관련 rate limit / 잠금 정책을 추가한다.
|
||||||
@@ -129,3 +131,4 @@
|
|||||||
- 구현할 때마다 완료된 항목은 체크하고, 큰 결정사항은 `HANDOFF.md`에도 함께 반영한다.
|
- 구현할 때마다 완료된 항목은 체크하고, 큰 결정사항은 `HANDOFF.md`에도 함께 반영한다.
|
||||||
- Resend 무료 플랜은 도메인 수 제약이 있으므로, 실제 인증 메일은 AWS SES 또는 별도 SMTP 서비스 전환을 전제로 설계하는 편이 안전하다.
|
- Resend 무료 플랜은 도메인 수 제약이 있으므로, 실제 인증 메일은 AWS SES 또는 별도 SMTP 서비스 전환을 전제로 설계하는 편이 안전하다.
|
||||||
- 관리자 기능은 읽기 전용 대시보드부터 시작하고, 실제 정리 액션은 권한/감사 로그 정책을 정한 뒤 추가하는 편이 안전하다.
|
- 관리자 기능은 읽기 전용 대시보드부터 시작하고, 실제 정리 액션은 권한/감사 로그 정책을 정한 뒤 추가하는 편이 안전하다.
|
||||||
|
- 관리자 아이디/비밀번호는 README나 HANDOFF에 실제 값으로 남기지 않고, Docker 배포용 비공개 `.env`에서만 관리한다.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
PORT=3001
|
PORT=3001
|
||||||
DATABASE_URL=postgresql://planner:planner1234@localhost:5432/ten_minute_planner
|
DATABASE_URL=postgresql://planner:planner1234@localhost:5432/ten_minute_planner
|
||||||
CORS_ORIGIN=http://localhost:5173
|
CORS_ORIGIN=http://localhost:5173
|
||||||
|
APP_BASE_URL=http://localhost:5173
|
||||||
|
ADMIN_ACCOUNT_ID=replace-with-private-admin-id
|
||||||
|
ADMIN_ACCOUNT_PASSWORD=replace-with-private-admin-password
|
||||||
|
ADMIN_ACCOUNT_EMAIL=admin@example.com
|
||||||
|
ADMIN_ACCOUNT_NICKNAME=Planner Admin
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ const envSchema = z.object({
|
|||||||
CORS_ORIGIN: z.string().default('http://localhost:5173'),
|
CORS_ORIGIN: z.string().default('http://localhost:5173'),
|
||||||
SESSION_TTL_DAYS: z.coerce.number().default(30),
|
SESSION_TTL_DAYS: z.coerce.number().default(30),
|
||||||
APP_BASE_URL: z.string().default('http://localhost:5173'),
|
APP_BASE_URL: z.string().default('http://localhost:5173'),
|
||||||
ADMIN_ACCOUNT_ID: z.string().default('planner-admin'),
|
ADMIN_ACCOUNT_ID: z.string().min(1),
|
||||||
ADMIN_ACCOUNT_PASSWORD: z.string().default('wps!vmffosj180204'),
|
ADMIN_ACCOUNT_PASSWORD: z.string().min(12),
|
||||||
ADMIN_ACCOUNT_EMAIL: z.string().default('planner-admin@planner.local'),
|
ADMIN_ACCOUNT_EMAIL: z.string().email(),
|
||||||
ADMIN_ACCOUNT_NICKNAME: z.string().default('Planner Admin'),
|
ADMIN_ACCOUNT_NICKNAME: z.string().min(1),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const env = envSchema.parse(process.env)
|
export const env = envSchema.parse(process.env)
|
||||||
|
|||||||
@@ -33,11 +33,11 @@ export async function registerAdminRoutes(app) {
|
|||||||
|
|
||||||
const [summary] = await db
|
const [summary] = await db
|
||||||
.select({
|
.select({
|
||||||
totalUsers: sql<number>`count(*)::int`,
|
totalUsers: sql`count(*)::int`,
|
||||||
totalAdmins: sql<number>`count(*) filter (where ${users.role} = 'admin')::int`,
|
totalAdmins: sql`count(*) filter (where ${users.role} = 'admin')::int`,
|
||||||
verifiedUsers: sql<number>`count(*) filter (where ${users.emailVerifiedAt} is not null)::int`,
|
verifiedUsers: sql`count(*) filter (where ${users.emailVerifiedAt} is not null)::int`,
|
||||||
activeUsers30d: sql<number>`count(*) filter (where ${users.lastLoginAt} >= now() - interval '30 days')::int`,
|
activeUsers30d: sql`count(*) filter (where ${users.lastLoginAt} >= now() - interval '30 days')::int`,
|
||||||
newUsers7d: sql<number>`count(*) filter (where ${users.createdAt} >= now() - interval '7 days')::int`,
|
newUsers7d: sql`count(*) filter (where ${users.createdAt} >= now() - interval '7 days')::int`,
|
||||||
})
|
})
|
||||||
.from(users)
|
.from(users)
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,14 @@ services:
|
|||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
container_name: ten-minute-postgres-dev
|
container_name: ten-minute-postgres-dev
|
||||||
environment:
|
env_file:
|
||||||
POSTGRES_DB: ten_minute_planner
|
- ./.env.dev
|
||||||
POSTGRES_USER: planner
|
|
||||||
POSTGRES_PASSWORD: planner1234
|
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_dev_data:/var/lib/postgresql/data
|
- postgres_dev_data:/var/lib/postgresql/data
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U planner -d ten_minute_planner"]
|
test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
@@ -22,9 +20,10 @@ services:
|
|||||||
container_name: ten-minute-backend-dev
|
container_name: ten-minute-backend-dev
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
command: sh -c "npm install && npm run dev"
|
command: sh -c "npm install && npm run dev"
|
||||||
|
env_file:
|
||||||
|
- ./.env.dev
|
||||||
environment:
|
environment:
|
||||||
PORT: 3001
|
PORT: 3001
|
||||||
DATABASE_URL: postgresql://planner:planner1234@postgres:5432/ten_minute_planner
|
|
||||||
CORS_ORIGIN: http://localhost:5173
|
CORS_ORIGIN: http://localhost:5173
|
||||||
SESSION_TTL_DAYS: 30
|
SESSION_TTL_DAYS: 30
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -2,16 +2,14 @@ services:
|
|||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
container_name: ten-minute-postgres
|
container_name: ten-minute-postgres
|
||||||
environment:
|
env_file:
|
||||||
POSTGRES_DB: ten_minute_planner
|
- ./.env
|
||||||
POSTGRES_USER: zenn
|
|
||||||
POSTGRES_PASSWORD: wps!vmffosj180204
|
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
ports:
|
ports:
|
||||||
- "45432:5432"
|
- "45432:5432"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U zenn -d ten_minute_planner"]
|
test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
@@ -21,9 +19,10 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
container_name: ten-minute-backend
|
container_name: ten-minute-backend
|
||||||
|
env_file:
|
||||||
|
- ./.env
|
||||||
environment:
|
environment:
|
||||||
PORT: 3001
|
PORT: 3001
|
||||||
DATABASE_URL: postgresql://zenn:wps%21vmffosj180204@postgres:5432/ten_minute_planner
|
|
||||||
CORS_ORIGIN: http://localhost:48081
|
CORS_ORIGIN: http://localhost:48081
|
||||||
SESSION_TTL_DAYS: 30
|
SESSION_TTL_DAYS: 30
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -46,4 +45,4 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|||||||
523
src/App.vue
523
src/App.vue
@@ -2,6 +2,7 @@
|
|||||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import AdminDashboard from './components/AdminDashboard.vue'
|
import AdminDashboard from './components/AdminDashboard.vue'
|
||||||
import AuthDialog from './components/AuthDialog.vue'
|
import AuthDialog from './components/AuthDialog.vue'
|
||||||
|
import GuideTooltip from './components/GuideTooltip.vue'
|
||||||
import GoalsDashboard from './components/GoalsDashboard.vue'
|
import GoalsDashboard from './components/GoalsDashboard.vue'
|
||||||
import MiniCalendar from './components/MiniCalendar.vue'
|
import MiniCalendar from './components/MiniCalendar.vue'
|
||||||
import PlannerPage from './components/PlannerPage.vue'
|
import PlannerPage from './components/PlannerPage.vue'
|
||||||
@@ -28,6 +29,8 @@ import {
|
|||||||
restorePlannerUiState,
|
restorePlannerUiState,
|
||||||
} from './lib/plannerStorage'
|
} from './lib/plannerStorage'
|
||||||
|
|
||||||
|
const GUIDE_TOOLTIP_STORAGE_KEY = 'ten-minute-guide-tooltips-hidden'
|
||||||
|
const DDAY_DISABLED_STORAGE_KEY = 'ten-minute-dday-disabled-dates'
|
||||||
const screenMode = ref('planner')
|
const screenMode = ref('planner')
|
||||||
const viewMode = ref('focus')
|
const viewMode = ref('focus')
|
||||||
const printLayout = ref('single')
|
const printLayout = ref('single')
|
||||||
@@ -76,6 +79,10 @@ const profileBusy = ref(false)
|
|||||||
const passwordBusy = ref(false)
|
const passwordBusy = ref(false)
|
||||||
const profileMessage = ref('')
|
const profileMessage = ref('')
|
||||||
const passwordMessage = ref('')
|
const passwordMessage = ref('')
|
||||||
|
const carryoverMessage = ref('')
|
||||||
|
const guideTooltipResetMessage = ref('')
|
||||||
|
const hiddenGuideTooltips = ref(readHiddenGuideTooltips())
|
||||||
|
const ddayDisabledDateKeys = ref(readDdayDisabledDateKeys())
|
||||||
const adminBusy = ref(false)
|
const adminBusy = ref(false)
|
||||||
const adminMessage = ref('')
|
const adminMessage = ref('')
|
||||||
const adminOverview = ref({
|
const adminOverview = ref({
|
||||||
@@ -103,6 +110,84 @@ let isHydratingRemoteRecords = false
|
|||||||
const syncTimers = new Map()
|
const syncTimers = new Map()
|
||||||
let syncToastTimer = null
|
let syncToastTimer = null
|
||||||
|
|
||||||
|
function readHiddenGuideTooltips() {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(window.localStorage.getItem(GUIDE_TOOLTIP_STORAGE_KEY) ?? '[]')
|
||||||
|
return Array.isArray(value) ? value : []
|
||||||
|
} catch (error) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistHiddenGuideTooltips() {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
window.localStorage.setItem(GUIDE_TOOLTIP_STORAGE_KEY, JSON.stringify(hiddenGuideTooltips.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGuideTooltipVisible(id) {
|
||||||
|
return !hiddenGuideTooltips.value.includes(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissGuideTooltip(id) {
|
||||||
|
if (hiddenGuideTooltips.value.includes(id)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hiddenGuideTooltips.value = [...hiddenGuideTooltips.value, id]
|
||||||
|
guideTooltipResetMessage.value = ''
|
||||||
|
persistHiddenGuideTooltips()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetGuideTooltips() {
|
||||||
|
hiddenGuideTooltips.value = []
|
||||||
|
guideTooltipResetMessage.value = '가이드 툴팁을 다시 볼 수 있게 했습니다.'
|
||||||
|
persistHiddenGuideTooltips()
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDdayDisabledDateKeys() {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(window.localStorage.getItem(DDAY_DISABLED_STORAGE_KEY) ?? '[]')
|
||||||
|
return Array.isArray(value) ? value : []
|
||||||
|
} catch (error) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistDdayDisabledDateKeys() {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
window.localStorage.setItem(DDAY_DISABLED_STORAGE_KEY, JSON.stringify(ddayDisabledDateKeys.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDdayDisabledForDate(dateKey) {
|
||||||
|
return ddayDisabledDateKeys.value.includes(dateKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDdayDisabledForDate(dateKey, disabled) {
|
||||||
|
if (disabled) {
|
||||||
|
if (!ddayDisabledDateKeys.value.includes(dateKey)) {
|
||||||
|
ddayDisabledDateKeys.value = [...ddayDisabledDateKeys.value, dateKey]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ddayDisabledDateKeys.value = ddayDisabledDateKeys.value.filter((key) => key !== dateKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
persistDdayDisabledDateKeys()
|
||||||
|
}
|
||||||
|
|
||||||
function createEmptyTimetable() {
|
function createEmptyTimetable() {
|
||||||
return Array.from({ length: timetableCellCount }, () => false)
|
return Array.from({ length: timetableCellCount }, () => false)
|
||||||
}
|
}
|
||||||
@@ -379,9 +464,12 @@ const activePlannerGoals = computed(() =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const plannerGoal = computed(() => activePlannerGoals.value[0] ?? null)
|
const plannerGoal = computed(() => activePlannerGoals.value[0] ?? null)
|
||||||
const plannerGoalToggleOn = computed(() => Boolean(plannerGoal.value) && planner.value.goalEnabled)
|
const selectedDateKey = computed(() => toKey(selectedDate.value))
|
||||||
|
const plannerGoalToggleOn = computed(() =>
|
||||||
|
Boolean(plannerGoal.value) && !isDdayDisabledForDate(selectedDateKey.value),
|
||||||
|
)
|
||||||
const plannerDday = computed(() => {
|
const plannerDday = computed(() => {
|
||||||
if (!planner.value.goalEnabled || !plannerGoal.value) {
|
if (!plannerGoalToggleOn.value || !plannerGoal.value) {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,7 +482,7 @@ const plannerDday = computed(() => {
|
|||||||
return `${badge} ${plannerGoal.value.title}`
|
return `${badge} ${plannerGoal.value.title}`
|
||||||
})
|
})
|
||||||
const showPlannerDday = computed(() =>
|
const showPlannerDday = computed(() =>
|
||||||
planner.value.goalEnabled && Boolean(plannerGoal.value),
|
plannerGoalToggleOn.value && Boolean(plannerGoal.value),
|
||||||
)
|
)
|
||||||
const hasActiveGoalForSelectedDate = computed(() => Boolean(plannerGoal.value))
|
const hasActiveGoalForSelectedDate = computed(() => Boolean(plannerGoal.value))
|
||||||
|
|
||||||
@@ -404,6 +492,9 @@ const filledTasks = computed(() =>
|
|||||||
const completedTasks = computed(() =>
|
const completedTasks = computed(() =>
|
||||||
filledTasks.value.filter((task) => task.checked).length,
|
filledTasks.value.filter((task) => task.checked).length,
|
||||||
)
|
)
|
||||||
|
const incompleteTasks = computed(() =>
|
||||||
|
planner.value.tasks.filter((task) => task.title.trim() && !task.checked),
|
||||||
|
)
|
||||||
const completionRate = computed(() => {
|
const completionRate = computed(() => {
|
||||||
if (filledTasks.value.length === 0) {
|
if (filledTasks.value.length === 0) {
|
||||||
return 0
|
return 0
|
||||||
@@ -451,6 +542,7 @@ function shiftDate(amount) {
|
|||||||
next.setDate(next.getDate() + amount)
|
next.setDate(next.getDate() + amount)
|
||||||
selectedDate.value = next
|
selectedDate.value = next
|
||||||
calendarViewDate.value = new Date(next)
|
calendarViewDate.value = new Date(next)
|
||||||
|
carryoverMessage.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function shiftCalendarMonth(amount) {
|
function shiftCalendarMonth(amount) {
|
||||||
@@ -468,6 +560,7 @@ function shiftCalendarYear(amount) {
|
|||||||
function selectDate(date) {
|
function selectDate(date) {
|
||||||
selectedDate.value = new Date(date)
|
selectedDate.value = new Date(date)
|
||||||
calendarViewDate.value = new Date(date)
|
calendarViewDate.value = new Date(date)
|
||||||
|
carryoverMessage.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateComment(record, value) {
|
function updateComment(record, value) {
|
||||||
@@ -480,8 +573,12 @@ function updateGoalEnabled(record, value) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setDdayDisabledForDate(selectedDateKey.value, !value)
|
||||||
record.goalEnabled = value
|
record.goalEnabled = value
|
||||||
schedulePlannerSyncForRecord(record)
|
|
||||||
|
if (hasPlannerContent(record)) {
|
||||||
|
schedulePlannerSyncForRecord(record)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateTaskLabel(record, { index, value }) {
|
function updateTaskLabel(record, { index, value }) {
|
||||||
@@ -499,6 +596,59 @@ function toggleTask(record, index) {
|
|||||||
schedulePlannerSyncForRecord(record)
|
schedulePlannerSyncForRecord(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearTasks(record, indexes) {
|
||||||
|
indexes.forEach((index) => {
|
||||||
|
if (!record.tasks[index]) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
record.tasks[index].title = ''
|
||||||
|
record.tasks[index].checked = false
|
||||||
|
})
|
||||||
|
schedulePlannerSyncForRecord(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
function carryIncompleteTasksToNextDay() {
|
||||||
|
const tasksToCarry = incompleteTasks.value.map((task) => ({
|
||||||
|
label: task.label,
|
||||||
|
title: task.title,
|
||||||
|
checked: false,
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (tasksToCarry.length === 0) {
|
||||||
|
carryoverMessage.value = '이월할 미완료 항목이 없습니다.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetRecord = secondaryPlanner.value
|
||||||
|
const emptyIndexes = targetRecord.tasks
|
||||||
|
.map((task, index) => ({ task, index }))
|
||||||
|
.filter(({ task }) => !task.title.trim())
|
||||||
|
.map(({ index }) => index)
|
||||||
|
const copyCount = Math.min(tasksToCarry.length, emptyIndexes.length)
|
||||||
|
|
||||||
|
if (copyCount === 0) {
|
||||||
|
carryoverMessage.value = '다음 날짜에 비어 있는 할 일 칸이 없습니다.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < copyCount; index += 1) {
|
||||||
|
const task = tasksToCarry[index]
|
||||||
|
targetRecord.tasks[emptyIndexes[index]] = {
|
||||||
|
...task,
|
||||||
|
checked: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
schedulePlannerSyncForRecord(targetRecord)
|
||||||
|
|
||||||
|
const nextDateLabel = createDateLabel(toKey(secondaryDate.value))
|
||||||
|
carryoverMessage.value =
|
||||||
|
copyCount === tasksToCarry.length
|
||||||
|
? `${nextDateLabel} 빈칸에 미완료 ${copyCount}개를 이월했습니다.`
|
||||||
|
: `${nextDateLabel} 빈칸 ${copyCount}개까지만 이월했습니다.`
|
||||||
|
}
|
||||||
|
|
||||||
function updateMemo(record, { index, value }) {
|
function updateMemo(record, { index, value }) {
|
||||||
record.memo[index].text = value
|
record.memo[index].text = value
|
||||||
schedulePlannerSyncForRecord(record)
|
schedulePlannerSyncForRecord(record)
|
||||||
@@ -698,9 +848,7 @@ const prevSnapshotItems = computed(() => {
|
|||||||
|
|
||||||
const readNextItems = computed(() => {
|
const readNextItems = computed(() => {
|
||||||
const nextDayFirstTask = secondaryPlanner.value.tasks.find((task) => task.title.trim())
|
const nextDayFirstTask = secondaryPlanner.value.tasks.find((task) => task.title.trim())
|
||||||
const incompleteTasks = planner.value.tasks.filter((task) => task.title.trim() && !task.checked)
|
const carryTask = incompleteTasks.value[0]?.title
|
||||||
const carryTask = incompleteTasks[0]?.title
|
|
||||||
const todayComment = planner.value.comment.trim()
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
nextDayFirstTask
|
nextDayFirstTask
|
||||||
@@ -708,12 +856,9 @@ const readNextItems = computed(() => {
|
|||||||
: carryTask
|
: carryTask
|
||||||
? `내일 이어갈 첫 작업: ${carryTask}`
|
? `내일 이어갈 첫 작업: ${carryTask}`
|
||||||
: '내일 첫 작업은 아직 비어 있습니다.',
|
: '내일 첫 작업은 아직 비어 있습니다.',
|
||||||
incompleteTasks.length > 0
|
incompleteTasks.value.length > 0
|
||||||
? `미완료 ${incompleteTasks.length}개를 이어서 볼 수 있습니다.`
|
? `아직 처리되지 않은 할 일이 ${incompleteTasks.value.length}개 있습니다.`
|
||||||
: '오늘 미완료 작업은 없습니다.',
|
: '오늘 할 일은 모두 처리되었습니다.',
|
||||||
todayComment
|
|
||||||
? `오늘 코멘트 이어보기: ${todayComment}`
|
|
||||||
: '오늘 코멘트는 아직 비어 있습니다.',
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -795,8 +940,8 @@ watch(
|
|||||||
watch(
|
watch(
|
||||||
[selectedDate, plannerGoal],
|
[selectedDate, plannerGoal],
|
||||||
() => {
|
() => {
|
||||||
if (!plannerGoal.value && planner.value.goalEnabled) {
|
if (plannerGoal.value && planner.value.goalEnabled === false && !isDdayDisabledForDate(selectedDateKey.value)) {
|
||||||
planner.value.goalEnabled = false
|
planner.value.goalEnabled = true
|
||||||
|
|
||||||
if (hasPlannerContent(planner.value)) {
|
if (hasPlannerContent(planner.value)) {
|
||||||
schedulePlannerSyncForRecord(planner.value)
|
schedulePlannerSyncForRecord(planner.value)
|
||||||
@@ -1546,7 +1691,6 @@ onBeforeUnmount(() => {
|
|||||||
<p class="text-[10px] font-bold tracking-[0.16em] text-stone-500">SIGNED IN</p>
|
<p class="text-[10px] font-bold tracking-[0.16em] text-stone-500">SIGNED IN</p>
|
||||||
<p class="mt-2 text-lg font-semibold tracking-[-0.03em] text-stone-900">{{ currentUser.nickname }}</p>
|
<p class="mt-2 text-lg font-semibold tracking-[-0.03em] text-stone-900">{{ currentUser.nickname }}</p>
|
||||||
<p class="mt-1 text-sm font-semibold text-stone-500">{{ currentUser.email }}</p>
|
<p class="mt-1 text-sm font-semibold text-stone-500">{{ currentUser.email }}</p>
|
||||||
<p class="mt-2 text-[11px] font-bold tracking-[0.16em] text-stone-400">{{ currentUser.role === 'admin' ? 'ADMIN' : 'USER' }}</p>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="mt-4 w-full rounded-full border border-stone-200 px-4 py-3 text-xs font-bold tracking-[0.16em] text-stone-600 transition hover:border-stone-400 hover:text-ink"
|
class="mt-4 w-full rounded-full border border-stone-200 px-4 py-3 text-xs font-bold tracking-[0.16em] text-stone-600 transition hover:border-stone-400 hover:text-ink"
|
||||||
@@ -1738,7 +1882,6 @@ onBeforeUnmount(() => {
|
|||||||
<p class="text-[10px] font-bold tracking-[0.16em] text-stone-500">SIGNED IN</p>
|
<p class="text-[10px] font-bold tracking-[0.16em] text-stone-500">SIGNED IN</p>
|
||||||
<p class="mt-2 text-lg font-semibold tracking-[-0.03em] text-stone-900">{{ currentUser.nickname }}</p>
|
<p class="mt-2 text-lg font-semibold tracking-[-0.03em] text-stone-900">{{ currentUser.nickname }}</p>
|
||||||
<p class="mt-1 text-sm font-semibold text-stone-500">{{ currentUser.email }}</p>
|
<p class="mt-1 text-sm font-semibold text-stone-500">{{ currentUser.email }}</p>
|
||||||
<p class="mt-2 text-[11px] font-bold tracking-[0.16em] text-stone-400">{{ currentUser.role === 'admin' ? 'ADMIN' : 'USER' }}</p>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="mt-4 w-full rounded-full border border-stone-200 px-4 py-3 text-xs font-bold tracking-[0.16em] text-stone-600 transition hover:border-stone-400 hover:text-ink"
|
class="mt-4 w-full rounded-full border border-stone-200 px-4 py-3 text-xs font-bold tracking-[0.16em] text-stone-600 transition hover:border-stone-400 hover:text-ink"
|
||||||
@@ -1872,18 +2015,16 @@ onBeforeUnmount(() => {
|
|||||||
>
|
>
|
||||||
<div class="mx-auto flex max-w-3xl flex-col gap-6 text-center">
|
<div class="mx-auto flex max-w-3xl flex-col gap-6 text-center">
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<p class="text-[11px] font-bold uppercase tracking-[0.28em] text-stone-500">Members Only</p>
|
<p class="text-[11px] font-bold uppercase tracking-[0.28em] text-stone-500">회원 전용 플래너</p>
|
||||||
<h2 class="text-3xl font-semibold tracking-[-0.05em] text-stone-900 sm:text-4xl">
|
<h2 class="text-3xl font-semibold tracking-[-0.05em] text-stone-900 sm:text-4xl">
|
||||||
로그인 후 플래너를 작성하고<br>
|
10 Minute Planner
|
||||||
클라우드에 안전하게 저장하세요
|
|
||||||
</h2>
|
</h2>
|
||||||
<p class="mx-auto max-w-2xl text-sm leading-7 text-stone-600 sm:text-base">
|
<p class="mx-auto max-w-2xl text-sm leading-7 text-stone-600 sm:text-base">
|
||||||
이제 플래너는 사용자 계정 기준으로 문서와 통계가 연결됩니다. 로그인하지 않은 상태에서는 데이터가 섞일 수 있어
|
로그인 후 나만의 10분 플래너를 작성하고,<br>날짜별 기록과 통계를 안전하게 이어가세요.
|
||||||
작성 화면을 열지 않도록 변경했습니다.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-4 rounded-[28px] border border-stone-200 bg-[#fbf7f0] p-5 text-left sm:grid-cols-3">
|
<div class="grid gap-4 rounded-[28px] border border-stone-200 bg-[#fbf7f0] p-5 text-left">
|
||||||
<article class="rounded-2xl border border-stone-200 bg-white px-4 py-5">
|
<article class="rounded-2xl border border-stone-200 bg-white px-4 py-5">
|
||||||
<p class="text-[10px] font-bold tracking-[0.18em] text-stone-500">ACCOUNT</p>
|
<p class="text-[10px] font-bold tracking-[0.18em] text-stone-500">ACCOUNT</p>
|
||||||
<p class="mt-3 text-sm font-semibold leading-6 text-stone-800">
|
<p class="mt-3 text-sm font-semibold leading-6 text-stone-800">
|
||||||
@@ -1962,6 +2103,7 @@ onBeforeUnmount(() => {
|
|||||||
@update:task-label="updateTaskLabel(planner, $event)"
|
@update:task-label="updateTaskLabel(planner, $event)"
|
||||||
@update:task-title="updateTaskTitle(planner, $event)"
|
@update:task-title="updateTaskTitle(planner, $event)"
|
||||||
@toggle:task="toggleTask(planner, $event)"
|
@toggle:task="toggleTask(planner, $event)"
|
||||||
|
@clear:tasks="clearTasks(planner, $event)"
|
||||||
@update:memo-label="updateMemoLabel(planner, $event)"
|
@update:memo-label="updateMemoLabel(planner, $event)"
|
||||||
@update:memo="updateMemo(planner, $event)"
|
@update:memo="updateMemo(planner, $event)"
|
||||||
@update:timetable="updateTimetable(planner, $event)"
|
@update:timetable="updateTimetable(planner, $event)"
|
||||||
@@ -2000,72 +2142,82 @@ onBeforeUnmount(() => {
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="rounded-[24px] border border-stone-200 bg-white/88 p-5 shadow-[0_12px_36px_rgba(28,25,23,0.05)]">
|
<section class="rounded-[24px] border border-stone-200 bg-white/88 p-4 shadow-[0_12px_36px_rgba(28,25,23,0.05)]">
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="grid gap-3">
|
||||||
<div>
|
<div class="flex items-center justify-between gap-3">
|
||||||
<p class="text-[11px] font-bold tracking-[0.22em] text-ink">TASK LABELS</p>
|
<div class="flex items-center gap-2">
|
||||||
<p class="mt-2 text-[11px] font-semibold leading-5 tracking-[0.06em] text-stone-600">
|
<p class="text-[11px] font-bold tracking-[0.22em] text-ink">TASK LABELS</p>
|
||||||
번호가 필요한 날만 빠르게 채우고, 필요 없으면 바로 비울 수 있습니다.
|
<GuideTooltip
|
||||||
</p>
|
title="Task Labels"
|
||||||
|
description="번호가 필요한 날만 빠르게 채우고, 필요 없으면 바로 비울 수 있습니다."
|
||||||
|
:visible="isGuideTooltipVisible('task-labels')"
|
||||||
|
@dismiss="dismissGuideTooltip('task-labels')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="relative h-8 w-16 shrink-0 rounded-full transition-colors duration-300 ease-out"
|
||||||
|
:class="areTaskLabelsNumbered(planner) ? 'bg-stone-900' : 'bg-stone-300'"
|
||||||
|
@click="areTaskLabelsNumbered(planner) ? clearTaskLabels(planner) : fillTaskLabelsWithNumbers(planner)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute left-1 top-1 h-6 w-6 transform-gpu rounded-full bg-white shadow-sm transition-transform duration-300 ease-out will-change-transform"
|
||||||
|
:class="areTaskLabelsNumbered(planner) ? 'translate-x-8' : 'translate-x-0'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-3 border-t border-stone-200 pt-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<p class="text-[11px] font-bold tracking-[0.22em] text-ink">D-DAY 사용</p>
|
||||||
|
<GuideTooltip
|
||||||
|
title="D-Day"
|
||||||
|
description="현재 날짜에 적용된 목표가 있을 때만 본문 D-DAY 표시를 켜고 끌 수 있습니다. 목표는 GOALS 화면에서 표시 기간을 지정하면 자동으로 연결됩니다."
|
||||||
|
:visible="isGuideTooltipVisible('planner-dday')"
|
||||||
|
@dismiss="dismissGuideTooltip('planner-dday')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="relative h-8 w-16 shrink-0 rounded-full transition-colors duration-300 ease-out disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:class="plannerGoalToggleOn ? 'bg-stone-900' : 'bg-stone-300'"
|
||||||
|
:disabled="!hasActiveGoalForSelectedDate"
|
||||||
|
@click="updateGoalEnabled(planner, !plannerGoalToggleOn)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute left-1 top-1 h-6 w-6 transform-gpu rounded-full bg-white shadow-sm transition-transform duration-300 ease-out will-change-transform"
|
||||||
|
:class="plannerGoalToggleOn ? 'translate-x-8' : 'translate-x-0'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="relative h-8 w-16 shrink-0 rounded-full transition-colors duration-300 ease-out"
|
|
||||||
:class="areTaskLabelsNumbered(planner) ? 'bg-stone-900' : 'bg-stone-300'"
|
|
||||||
@click="areTaskLabelsNumbered(planner) ? clearTaskLabels(planner) : fillTaskLabelsWithNumbers(planner)"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="absolute left-1 top-1 h-6 w-6 transform-gpu rounded-full bg-white shadow-sm transition-transform duration-300 ease-out will-change-transform"
|
|
||||||
:class="areTaskLabelsNumbered(planner) ? 'translate-x-8' : 'translate-x-0'"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="rounded-[24px] border border-stone-200 bg-white/88 p-5 shadow-[0_12px_36px_rgba(28,25,23,0.05)]">
|
<section class="rounded-[24px] border border-stone-200 bg-white/88 p-5 shadow-[0_12px_36px_rgba(28,25,23,0.05)]">
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-[11px] font-bold tracking-[0.22em] text-ink">D-DAY 사용</p>
|
<p class="text-[11px] font-bold tracking-[0.22em] text-ink">미완료 항목 이월</p>
|
||||||
<p class="mt-2 text-[11px] font-semibold leading-5 tracking-[0.06em] text-stone-600">
|
<p class="mt-2 text-[11px] font-semibold leading-5 tracking-[0.06em] text-stone-600">
|
||||||
현재 날짜에 적용된 목표가 있을 때만 본문 D-DAY 표시를 켜고 끌 수 있습니다.
|
체크하지 못한 할 일을 다음 날짜의 빈칸에 순서대로 복사합니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="relative h-8 w-16 shrink-0 rounded-full transition-colors duration-300 ease-out disabled:cursor-not-allowed disabled:opacity-50"
|
class="w-full rounded-full border border-stone-900 bg-stone-900 px-4 py-3 text-xs font-bold tracking-[0.14em] text-white transition hover:bg-stone-700 disabled:cursor-not-allowed disabled:border-stone-200 disabled:bg-stone-200 disabled:text-stone-500"
|
||||||
:class="plannerGoalToggleOn ? 'bg-stone-900' : 'bg-stone-300'"
|
:disabled="incompleteTasks.length === 0"
|
||||||
:disabled="!hasActiveGoalForSelectedDate"
|
@click="carryIncompleteTasksToNextDay"
|
||||||
@click="updateGoalEnabled(planner, !planner.goalEnabled)"
|
|
||||||
>
|
>
|
||||||
<span
|
다음날로 이월하기
|
||||||
class="absolute left-1 top-1 h-6 w-6 transform-gpu rounded-full bg-white shadow-sm transition-transform duration-300 ease-out will-change-transform"
|
|
||||||
:class="plannerGoalToggleOn ? 'translate-x-8' : 'translate-x-0'"
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<p
|
||||||
|
v-if="carryoverMessage"
|
||||||
<div class="mt-4 space-y-3">
|
class="rounded-2xl border border-stone-200 bg-[#fbf7f0] px-4 py-3 text-[11px] font-semibold leading-5 tracking-[0.06em] text-stone-600"
|
||||||
<div v-if="plannerGoal" class="rounded-2xl border border-stone-200 bg-white px-4 py-3">
|
>
|
||||||
<p class="text-[10px] font-bold tracking-[0.16em] text-stone-500">현재 목표</p>
|
{{ carryoverMessage }}
|
||||||
<p class="mt-2 text-sm font-semibold tracking-[0.02em] text-stone-900">{{ plannerGoal.title }}</p>
|
</p>
|
||||||
<p class="mt-1 text-[11px] font-semibold tracking-[0.06em] text-stone-500">
|
|
||||||
목표일 {{ plannerGoal.targetDate }} / 적용 {{ plannerGoal.activeFrom }} ~ {{ plannerGoal.activeUntil }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="plannerGoal" class="rounded-2xl border border-stone-200 bg-[#fbf7f0] px-4 py-4">
|
|
||||||
<p class="text-[11px] font-semibold leading-5 tracking-[0.06em] text-stone-600">
|
|
||||||
이 날짜에는 이미 적용된 목표가 있으므로 토글만으로 표시 여부를 빠르게 조절할 수 있습니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="rounded-2xl border border-dashed border-stone-300 bg-white/70 px-4 py-4">
|
|
||||||
<p class="text-[11px] font-semibold leading-5 tracking-[0.06em] text-stone-500">
|
|
||||||
현재 날짜에 적용된 목표가 없습니다. GOALS 화면에서 표시 기간을 지정하면 여기 토글이 자동으로 활성화됩니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-4" :class="isWideFocusSidebar ? '' : 'max-w-[360px]'">
|
<div class="grid gap-4" :class="isWideFocusSidebar ? '' : 'max-w-[360px]'">
|
||||||
@@ -2097,35 +2249,35 @@ onBeforeUnmount(() => {
|
|||||||
{{ nextDaySummary }}
|
{{ nextDaySummary }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 border-t border-stone-200 pt-5">
|
||||||
|
<p class="text-[11px] font-bold tracking-[0.22em] text-ink">READ NEXT</p>
|
||||||
|
<div class="mt-3 grid gap-3">
|
||||||
|
<p
|
||||||
|
v-for="item in readNextItems"
|
||||||
|
:key="item"
|
||||||
|
class="rounded-2xl border border-stone-200 bg-[#fbf7f0] px-4 py-4 text-[11px] font-semibold leading-5 tracking-[0.06em] text-stone-700"
|
||||||
|
>
|
||||||
|
{{ item || ' ' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 border-t border-stone-200 pt-5">
|
||||||
|
<p class="text-[11px] font-bold tracking-[0.22em] text-ink">PREV SNAPSHOT</p>
|
||||||
|
<div class="mt-3 grid gap-3">
|
||||||
|
<p
|
||||||
|
v-for="item in prevSnapshotItems"
|
||||||
|
:key="item"
|
||||||
|
class="rounded-2xl border border-stone-200 bg-white px-4 py-4 text-[11px] font-semibold leading-5 tracking-[0.06em] text-stone-700"
|
||||||
|
>
|
||||||
|
{{ item || ' ' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="rounded-[24px] border border-stone-200 bg-white/82 p-5 shadow-[0_12px_36px_rgba(28,25,23,0.05)]" :class="isWideFocusSidebar ? '2xl:col-span-2' : ''">
|
|
||||||
<p class="mb-4 text-[11px] font-bold tracking-[0.22em] text-ink">READ NEXT</p>
|
|
||||||
<div class="grid gap-3" :class="isWideFocusSidebar ? 'sm:grid-cols-3 2xl:grid-cols-3' : 'grid-cols-1'">
|
|
||||||
<p
|
|
||||||
v-for="item in readNextItems"
|
|
||||||
:key="item"
|
|
||||||
class="rounded-2xl border border-stone-200 bg-[#fbf7f0] px-4 py-4 text-[11px] font-semibold leading-5 tracking-[0.06em] text-stone-700"
|
|
||||||
>
|
|
||||||
{{ item || ' ' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="rounded-[24px] border border-stone-200 bg-white/82 p-5 shadow-[0_12px_36px_rgba(28,25,23,0.05)]" :class="isWideFocusSidebar ? '2xl:col-span-2' : ''">
|
|
||||||
<p class="mb-4 text-[11px] font-bold tracking-[0.22em] text-ink">PREV SNAPSHOT</p>
|
|
||||||
<div class="grid gap-3" :class="isWideFocusSidebar ? 'sm:grid-cols-3 2xl:grid-cols-3' : 'grid-cols-1'">
|
|
||||||
<p
|
|
||||||
v-for="item in prevSnapshotItems"
|
|
||||||
:key="item"
|
|
||||||
class="rounded-2xl border border-stone-200 bg-white px-4 py-4 text-[11px] font-semibold leading-5 tracking-[0.06em] text-stone-700"
|
|
||||||
>
|
|
||||||
{{ item || ' ' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -2161,72 +2313,82 @@ onBeforeUnmount(() => {
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="rounded-[24px] border border-stone-200 bg-white/88 p-5 shadow-[0_12px_36px_rgba(28,25,23,0.05)]">
|
<section class="rounded-[24px] border border-stone-200 bg-white/88 p-4 shadow-[0_12px_36px_rgba(28,25,23,0.05)]">
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="grid gap-3">
|
||||||
<div>
|
<div class="flex items-center justify-between gap-3">
|
||||||
<p class="text-[11px] font-bold tracking-[0.22em] text-ink">TASK LABELS</p>
|
<div class="flex items-center gap-2">
|
||||||
<p class="mt-2 text-[11px] font-semibold leading-5 tracking-[0.06em] text-stone-600">
|
<p class="text-[11px] font-bold tracking-[0.22em] text-ink">TASK LABELS</p>
|
||||||
번호가 필요한 날만 빠르게 채우고, 필요 없으면 바로 비울 수 있습니다.
|
<GuideTooltip
|
||||||
</p>
|
title="Task Labels"
|
||||||
|
description="번호가 필요한 날만 빠르게 채우고, 필요 없으면 바로 비울 수 있습니다."
|
||||||
|
:visible="isGuideTooltipVisible('task-labels')"
|
||||||
|
@dismiss="dismissGuideTooltip('task-labels')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="relative h-8 w-16 shrink-0 rounded-full transition-colors duration-300 ease-out"
|
||||||
|
:class="areTaskLabelsNumbered(planner) ? 'bg-stone-900' : 'bg-stone-300'"
|
||||||
|
@click="areTaskLabelsNumbered(planner) ? clearTaskLabels(planner) : fillTaskLabelsWithNumbers(planner)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute left-1 top-1 h-6 w-6 transform-gpu rounded-full bg-white shadow-sm transition-transform duration-300 ease-out will-change-transform"
|
||||||
|
:class="areTaskLabelsNumbered(planner) ? 'translate-x-8' : 'translate-x-0'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-3 border-t border-stone-200 pt-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<p class="text-[11px] font-bold tracking-[0.22em] text-ink">D-DAY 사용</p>
|
||||||
|
<GuideTooltip
|
||||||
|
title="D-Day"
|
||||||
|
description="현재 날짜에 적용된 목표가 있을 때만 본문 D-DAY 표시를 켜고 끌 수 있습니다. 목표는 GOALS 화면에서 표시 기간을 지정하면 자동으로 연결됩니다."
|
||||||
|
:visible="isGuideTooltipVisible('planner-dday')"
|
||||||
|
@dismiss="dismissGuideTooltip('planner-dday')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="relative h-8 w-16 shrink-0 rounded-full transition-colors duration-300 ease-out disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:class="plannerGoalToggleOn ? 'bg-stone-900' : 'bg-stone-300'"
|
||||||
|
:disabled="!hasActiveGoalForSelectedDate"
|
||||||
|
@click="updateGoalEnabled(planner, !plannerGoalToggleOn)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute left-1 top-1 h-6 w-6 transform-gpu rounded-full bg-white shadow-sm transition-transform duration-300 ease-out will-change-transform"
|
||||||
|
:class="plannerGoalToggleOn ? 'translate-x-8' : 'translate-x-0'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="relative h-8 w-16 shrink-0 rounded-full transition-colors duration-300 ease-out"
|
|
||||||
:class="areTaskLabelsNumbered(planner) ? 'bg-stone-900' : 'bg-stone-300'"
|
|
||||||
@click="areTaskLabelsNumbered(planner) ? clearTaskLabels(planner) : fillTaskLabelsWithNumbers(planner)"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="absolute left-1 top-1 h-6 w-6 transform-gpu rounded-full bg-white shadow-sm transition-transform duration-300 ease-out will-change-transform"
|
|
||||||
:class="areTaskLabelsNumbered(planner) ? 'translate-x-8' : 'translate-x-0'"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="rounded-[24px] border border-stone-200 bg-white/88 p-5 shadow-[0_12px_36px_rgba(28,25,23,0.05)]">
|
<section class="rounded-[24px] border border-stone-200 bg-white/88 p-5 shadow-[0_12px_36px_rgba(28,25,23,0.05)]">
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-[11px] font-bold tracking-[0.22em] text-ink">D-DAY 사용</p>
|
<p class="text-[11px] font-bold tracking-[0.22em] text-ink">미완료 항목 이월</p>
|
||||||
<p class="mt-2 text-[11px] font-semibold leading-5 tracking-[0.06em] text-stone-600">
|
<p class="mt-2 text-[11px] font-semibold leading-5 tracking-[0.06em] text-stone-600">
|
||||||
현재 날짜에 적용된 목표가 있을 때만 본문 D-DAY 표시를 켜고 끌 수 있습니다.
|
체크하지 못한 할 일을 다음 날짜의 빈칸에 순서대로 복사합니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="relative h-8 w-16 shrink-0 rounded-full transition-colors duration-300 ease-out disabled:cursor-not-allowed disabled:opacity-50"
|
class="w-full rounded-full border border-stone-900 bg-stone-900 px-4 py-3 text-xs font-bold tracking-[0.14em] text-white transition hover:bg-stone-700 disabled:cursor-not-allowed disabled:border-stone-200 disabled:bg-stone-200 disabled:text-stone-500"
|
||||||
:class="plannerGoalToggleOn ? 'bg-stone-900' : 'bg-stone-300'"
|
:disabled="incompleteTasks.length === 0"
|
||||||
:disabled="!hasActiveGoalForSelectedDate"
|
@click="carryIncompleteTasksToNextDay"
|
||||||
@click="updateGoalEnabled(planner, !planner.goalEnabled)"
|
|
||||||
>
|
>
|
||||||
<span
|
다음날로 이월하기
|
||||||
class="absolute left-1 top-1 h-6 w-6 transform-gpu rounded-full bg-white shadow-sm transition-transform duration-300 ease-out will-change-transform"
|
|
||||||
:class="plannerGoalToggleOn ? 'translate-x-8' : 'translate-x-0'"
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<p
|
||||||
|
v-if="carryoverMessage"
|
||||||
<div class="mt-4 space-y-3">
|
class="rounded-2xl border border-stone-200 bg-[#fbf7f0] px-4 py-3 text-[11px] font-semibold leading-5 tracking-[0.06em] text-stone-600"
|
||||||
<div v-if="plannerGoal" class="rounded-2xl border border-stone-200 bg-white px-4 py-3">
|
>
|
||||||
<p class="text-[10px] font-bold tracking-[0.16em] text-stone-500">현재 목표</p>
|
{{ carryoverMessage }}
|
||||||
<p class="mt-2 text-sm font-semibold tracking-[0.02em] text-stone-900">{{ plannerGoal.title }}</p>
|
</p>
|
||||||
<p class="mt-1 text-[11px] font-semibold tracking-[0.06em] text-stone-500">
|
|
||||||
목표일 {{ plannerGoal.targetDate }} / 적용 {{ plannerGoal.activeFrom }} ~ {{ plannerGoal.activeUntil }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="plannerGoal" class="rounded-2xl border border-stone-200 bg-[#fbf7f0] px-4 py-4">
|
|
||||||
<p class="text-[11px] font-semibold leading-5 tracking-[0.06em] text-stone-600">
|
|
||||||
이 날짜에는 이미 적용된 목표가 있으므로 토글만으로 표시 여부를 빠르게 조절할 수 있습니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="rounded-2xl border border-dashed border-stone-300 bg-white/70 px-4 py-4">
|
|
||||||
<p class="text-[11px] font-semibold leading-5 tracking-[0.06em] text-stone-500">
|
|
||||||
현재 날짜에 적용된 목표가 없습니다. GOALS 화면에서 표시 기간을 지정하면 여기 토글이 자동으로 활성화됩니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid max-w-[360px] gap-4">
|
<div class="grid max-w-[360px] gap-4">
|
||||||
@@ -2258,35 +2420,35 @@ onBeforeUnmount(() => {
|
|||||||
{{ nextDaySummary }}
|
{{ nextDaySummary }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 border-t border-stone-200 pt-5">
|
||||||
|
<p class="text-[11px] font-bold tracking-[0.22em] text-ink">READ NEXT</p>
|
||||||
|
<div class="mt-3 grid gap-3">
|
||||||
|
<p
|
||||||
|
v-for="item in readNextItems"
|
||||||
|
:key="item"
|
||||||
|
class="rounded-2xl border border-stone-200 bg-[#fbf7f0] px-4 py-4 text-[11px] font-semibold leading-5 tracking-[0.06em] text-stone-700"
|
||||||
|
>
|
||||||
|
{{ item || ' ' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 border-t border-stone-200 pt-5">
|
||||||
|
<p class="text-[11px] font-bold tracking-[0.22em] text-ink">PREV SNAPSHOT</p>
|
||||||
|
<div class="mt-3 grid gap-3">
|
||||||
|
<p
|
||||||
|
v-for="item in prevSnapshotItems"
|
||||||
|
:key="item"
|
||||||
|
class="rounded-2xl border border-stone-200 bg-white px-4 py-4 text-[11px] font-semibold leading-5 tracking-[0.06em] text-stone-700"
|
||||||
|
>
|
||||||
|
{{ item || ' ' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="rounded-[24px] border border-stone-200 bg-white/82 p-5 shadow-[0_12px_36px_rgba(28,25,23,0.05)]">
|
|
||||||
<p class="mb-4 text-[11px] font-bold tracking-[0.22em] text-ink">READ NEXT</p>
|
|
||||||
<div class="grid gap-3 grid-cols-1">
|
|
||||||
<p
|
|
||||||
v-for="item in readNextItems"
|
|
||||||
:key="item"
|
|
||||||
class="rounded-2xl border border-stone-200 bg-[#fbf7f0] px-4 py-4 text-[11px] font-semibold leading-5 tracking-[0.06em] text-stone-700"
|
|
||||||
>
|
|
||||||
{{ item || ' ' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="rounded-[24px] border border-stone-200 bg-white/82 p-5 shadow-[0_12px_36px_rgba(28,25,23,0.05)]">
|
|
||||||
<p class="mb-4 text-[11px] font-bold tracking-[0.22em] text-ink">PREV SNAPSHOT</p>
|
|
||||||
<div class="grid gap-3 grid-cols-1">
|
|
||||||
<p
|
|
||||||
v-for="item in prevSnapshotItems"
|
|
||||||
:key="item"
|
|
||||||
class="rounded-2xl border border-stone-200 bg-white px-4 py-4 text-[11px] font-semibold leading-5 tracking-[0.06em] text-stone-700"
|
|
||||||
>
|
|
||||||
{{ item || ' ' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</Transition>
|
</Transition>
|
||||||
@@ -2315,6 +2477,7 @@ onBeforeUnmount(() => {
|
|||||||
@update:task-label="updateTaskLabel(planner, $event)"
|
@update:task-label="updateTaskLabel(planner, $event)"
|
||||||
@update:task-title="updateTaskTitle(planner, $event)"
|
@update:task-title="updateTaskTitle(planner, $event)"
|
||||||
@toggle:task="toggleTask(planner, $event)"
|
@toggle:task="toggleTask(planner, $event)"
|
||||||
|
@clear:tasks="clearTasks(planner, $event)"
|
||||||
@update:memo-label="updateMemoLabel(planner, $event)"
|
@update:memo-label="updateMemoLabel(planner, $event)"
|
||||||
@update:memo="updateMemo(planner, $event)"
|
@update:memo="updateMemo(planner, $event)"
|
||||||
@update:timetable="updateTimetable(planner, $event)"
|
@update:timetable="updateTimetable(planner, $event)"
|
||||||
@@ -2338,6 +2501,7 @@ onBeforeUnmount(() => {
|
|||||||
@update:task-label="updateTaskLabel(secondaryPlanner, $event)"
|
@update:task-label="updateTaskLabel(secondaryPlanner, $event)"
|
||||||
@update:task-title="updateTaskTitle(secondaryPlanner, $event)"
|
@update:task-title="updateTaskTitle(secondaryPlanner, $event)"
|
||||||
@toggle:task="toggleTask(secondaryPlanner, $event)"
|
@toggle:task="toggleTask(secondaryPlanner, $event)"
|
||||||
|
@clear:tasks="clearTasks(secondaryPlanner, $event)"
|
||||||
@update:memo-label="updateMemoLabel(secondaryPlanner, $event)"
|
@update:memo-label="updateMemoLabel(secondaryPlanner, $event)"
|
||||||
@update:memo="updateMemo(secondaryPlanner, $event)"
|
@update:memo="updateMemo(secondaryPlanner, $event)"
|
||||||
@update:timetable="updateTimetable(secondaryPlanner, $event)"
|
@update:timetable="updateTimetable(secondaryPlanner, $event)"
|
||||||
@@ -2375,10 +2539,12 @@ onBeforeUnmount(() => {
|
|||||||
:password-busy="passwordBusy"
|
:password-busy="passwordBusy"
|
||||||
:profile-message="profileMessage"
|
:profile-message="profileMessage"
|
||||||
:password-message="passwordMessage"
|
:password-message="passwordMessage"
|
||||||
|
:guide-tooltip-reset-message="guideTooltipResetMessage"
|
||||||
@update:profile-field="updateProfileField"
|
@update:profile-field="updateProfileField"
|
||||||
@update:password-field="updatePasswordField"
|
@update:password-field="updatePasswordField"
|
||||||
@submit:profile="submitProfileForm"
|
@submit:profile="submitProfileForm"
|
||||||
@submit:password="submitPasswordForm"
|
@submit:password="submitPasswordForm"
|
||||||
|
@reset-guide-tooltips="resetGuideTooltips"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AdminDashboard
|
<AdminDashboard
|
||||||
@@ -2424,6 +2590,7 @@ onBeforeUnmount(() => {
|
|||||||
@update:task-label="updateTaskLabel(planner, $event)"
|
@update:task-label="updateTaskLabel(planner, $event)"
|
||||||
@update:task-title="updateTaskTitle(planner, $event)"
|
@update:task-title="updateTaskTitle(planner, $event)"
|
||||||
@toggle:task="toggleTask(planner, $event)"
|
@toggle:task="toggleTask(planner, $event)"
|
||||||
|
@clear:tasks="clearTasks(planner, $event)"
|
||||||
@update:memo-label="updateMemoLabel(planner, $event)"
|
@update:memo-label="updateMemoLabel(planner, $event)"
|
||||||
@update:memo="updateMemo(planner, $event)"
|
@update:memo="updateMemo(planner, $event)"
|
||||||
@update:timetable="updateTimetable(planner, $event)"
|
@update:timetable="updateTimetable(planner, $event)"
|
||||||
@@ -2449,6 +2616,7 @@ onBeforeUnmount(() => {
|
|||||||
@update:task-label="updateTaskLabel(planner, $event)"
|
@update:task-label="updateTaskLabel(planner, $event)"
|
||||||
@update:task-title="updateTaskTitle(planner, $event)"
|
@update:task-title="updateTaskTitle(planner, $event)"
|
||||||
@toggle:task="toggleTask(planner, $event)"
|
@toggle:task="toggleTask(planner, $event)"
|
||||||
|
@clear:tasks="clearTasks(planner, $event)"
|
||||||
@update:memo-label="updateMemoLabel(planner, $event)"
|
@update:memo-label="updateMemoLabel(planner, $event)"
|
||||||
@update:memo="updateMemo(planner, $event)"
|
@update:memo="updateMemo(planner, $event)"
|
||||||
@update:timetable="updateTimetable(planner, $event)"
|
@update:timetable="updateTimetable(planner, $event)"
|
||||||
@@ -2471,6 +2639,7 @@ onBeforeUnmount(() => {
|
|||||||
@update:task-label="updateTaskLabel(secondaryPlanner, $event)"
|
@update:task-label="updateTaskLabel(secondaryPlanner, $event)"
|
||||||
@update:task-title="updateTaskTitle(secondaryPlanner, $event)"
|
@update:task-title="updateTaskTitle(secondaryPlanner, $event)"
|
||||||
@toggle:task="toggleTask(secondaryPlanner, $event)"
|
@toggle:task="toggleTask(secondaryPlanner, $event)"
|
||||||
|
@clear:tasks="clearTasks(secondaryPlanner, $event)"
|
||||||
@update:memo-label="updateMemoLabel(secondaryPlanner, $event)"
|
@update:memo-label="updateMemoLabel(secondaryPlanner, $event)"
|
||||||
@update:memo="updateMemo(secondaryPlanner, $event)"
|
@update:memo="updateMemo(secondaryPlanner, $event)"
|
||||||
@update:timetable="updateTimetable(secondaryPlanner, $event)"
|
@update:timetable="updateTimetable(secondaryPlanner, $event)"
|
||||||
|
|||||||
@@ -45,12 +45,12 @@ function updateField(field, event) {
|
|||||||
<div class="w-full max-w-md rounded-[28px] border border-white/60 bg-[#f6f1e8] p-6 shadow-2xl sm:p-7">
|
<div class="w-full max-w-md rounded-[28px] border border-white/60 bg-[#f6f1e8] p-6 shadow-2xl sm:p-7">
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex items-start justify-between gap-4">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<p class="text-[11px] font-bold uppercase tracking-[0.24em] text-stone-500">Account</p>
|
<p class="text-[11px] font-bold uppercase tracking-[0.24em] text-stone-500">계정 시작</p>
|
||||||
<h2 class="text-2xl font-semibold tracking-[-0.04em] text-stone-900">
|
<h2 class="text-2xl font-semibold tracking-[-0.04em] text-stone-900">
|
||||||
{{ mode === 'login' ? '로그인' : '회원가입' }}
|
{{ mode === 'login' ? '로그인' : '회원가입' }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-sm leading-6 text-stone-600">
|
<p class="text-sm leading-6 text-stone-600">
|
||||||
{{ mode === 'login' ? '저장된 플래너를 다시 이어서 볼 수 있습니다.' : '사용자별 기록과 통계를 연결하기 위한 계정을 만듭니다.' }}
|
{{ mode === 'login' ? '작성하던 플래너를 이어서 기록하세요.' : '나만의 플래너와 통계를 안전하게 보관할 계정을 만듭니다.' }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -82,7 +82,7 @@ function updateField(field, event) {
|
|||||||
:value="form.email"
|
:value="form.email"
|
||||||
:type="mode === 'login' ? 'text' : 'email'"
|
:type="mode === 'login' ? 'text' : 'email'"
|
||||||
class="w-full rounded-2xl border border-stone-300 bg-white px-4 py-3 text-sm font-semibold text-stone-800 outline-none transition focus:border-stone-500"
|
class="w-full rounded-2xl border border-stone-300 bg-white px-4 py-3 text-sm font-semibold text-stone-800 outline-none transition focus:border-stone-500"
|
||||||
:placeholder="mode === 'login' ? 'zenn@example.com 또는 planner-admin' : 'zenn@example.com'"
|
:placeholder="mode === 'login' ? '이메일 또는 아이디를 입력해 주세요.' : 'you@example.com'"
|
||||||
@input="updateField('email', $event)"
|
@input="updateField('email', $event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -110,7 +110,7 @@ function updateField(field, event) {
|
|||||||
class="w-full rounded-full bg-stone-900 px-5 py-3 text-xs font-bold tracking-[0.18em] text-white transition hover:bg-stone-700 disabled:cursor-not-allowed disabled:bg-stone-400"
|
class="w-full rounded-full bg-stone-900 px-5 py-3 text-xs font-bold tracking-[0.18em] text-white transition hover:bg-stone-700 disabled:cursor-not-allowed disabled:bg-stone-400"
|
||||||
:disabled="busy"
|
:disabled="busy"
|
||||||
>
|
>
|
||||||
{{ busy ? '처리 중...' : mode === 'login' ? 'LOGIN' : 'SIGN UP' }}
|
{{ busy ? '처리 중...' : mode === 'login' ? '로그인하기' : '가입하기' }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
90
src/components/GuideTooltip.vue
Normal file
90
src/components/GuideTooltip.vue
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onBeforeUnmount, ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
visible: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['dismiss'])
|
||||||
|
|
||||||
|
const open = ref(false)
|
||||||
|
const rootRef = ref(null)
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
open.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
if (!props.visible) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
open.value = !open.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeFromOutside(event) {
|
||||||
|
if (!open.value || rootRef.value?.contains(event.target)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismiss() {
|
||||||
|
emit('dismiss')
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('pointerdown', closeFromOutside)
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('pointerdown', closeFromOutside)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span
|
||||||
|
v-if="visible"
|
||||||
|
ref="rootRef"
|
||||||
|
class="relative inline-flex"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-5 w-5 items-center justify-center rounded-full border border-stone-300 bg-white text-[10px] font-bold text-stone-500 transition hover:border-stone-500 hover:text-stone-900 focus-visible:ring-2 focus-visible:ring-stone-900 focus-visible:ring-offset-2"
|
||||||
|
aria-label="가이드 보기"
|
||||||
|
:aria-expanded="open"
|
||||||
|
@click.stop="toggle"
|
||||||
|
>
|
||||||
|
?
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-if="open"
|
||||||
|
class="absolute left-0 top-7 z-50 w-64 rounded-2xl border border-stone-200 bg-white p-4 text-left shadow-[0_18px_50px_rgba(28,25,23,0.16)]"
|
||||||
|
@pointerdown.stop
|
||||||
|
>
|
||||||
|
<span class="block text-[10px] font-bold uppercase tracking-[0.2em] text-stone-500">{{ title }}</span>
|
||||||
|
<span class="mt-2 block text-[11px] font-semibold leading-5 tracking-[0.04em] text-stone-700">{{ description }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mt-3 rounded-full border border-stone-200 px-3 py-2 text-[10px] font-bold tracking-[0.14em] text-stone-600 transition hover:border-stone-500 hover:text-stone-900"
|
||||||
|
@click="dismiss"
|
||||||
|
>
|
||||||
|
더 이상 보지 않기
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onBeforeUnmount } from 'vue'
|
import { onBeforeUnmount, ref } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
dateMain: {
|
dateMain: {
|
||||||
@@ -57,12 +57,15 @@ const emit = defineEmits([
|
|||||||
'update:task-label',
|
'update:task-label',
|
||||||
'update:task-title',
|
'update:task-title',
|
||||||
'toggle:task',
|
'toggle:task',
|
||||||
|
'clear:tasks',
|
||||||
'update:memo-label',
|
'update:memo-label',
|
||||||
'update:memo',
|
'update:memo',
|
||||||
'update:timetable',
|
'update:timetable',
|
||||||
])
|
])
|
||||||
|
|
||||||
let dragState = null
|
let dragState = null
|
||||||
|
let taskSelectionDrag = null
|
||||||
|
const selectedTaskIndexes = ref(new Set())
|
||||||
|
|
||||||
function shouldShowTaskPlaceholder(index) {
|
function shouldShowTaskPlaceholder(index) {
|
||||||
return index === 0 && props.tasks.every((task) => !task.title.trim())
|
return index === 0 && props.tasks.every((task) => !task.title.trim())
|
||||||
@@ -111,9 +114,114 @@ function stopTimetableDrag() {
|
|||||||
dragState = null
|
dragState = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTaskSelectionBlockedTarget(target) {
|
||||||
|
return Boolean(target.closest('button, textarea, [contenteditable="true"]'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTaskSelectionRange(startIndex, endIndex) {
|
||||||
|
const rangeStart = Math.min(startIndex, endIndex)
|
||||||
|
const rangeEnd = Math.max(startIndex, endIndex)
|
||||||
|
|
||||||
|
return new Set(Array.from({ length: rangeEnd - rangeStart + 1 }, (_, offset) => rangeStart + offset))
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTaskSelected(index) {
|
||||||
|
return selectedTaskIndexes.value.has(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearTaskSelection() {
|
||||||
|
selectedTaskIndexes.value = new Set()
|
||||||
|
taskSelectionDrag = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearTaskSelectionOnFocus() {
|
||||||
|
if (taskSelectionDrag) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTaskSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
function startTaskSelection(index, event) {
|
||||||
|
if (event.button !== 0 || isTaskSelectionBlockedTarget(event.target)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedTaskIndexes.value = new Set()
|
||||||
|
taskSelectionDrag = {
|
||||||
|
startIndex: index,
|
||||||
|
isSelecting: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveTaskSelection(index) {
|
||||||
|
if (!taskSelectionDrag) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!taskSelectionDrag.isSelecting && index === taskSelectionDrag.startIndex) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!taskSelectionDrag.isSelecting) {
|
||||||
|
taskSelectionDrag.isSelecting = true
|
||||||
|
document.activeElement?.blur?.()
|
||||||
|
window.getSelection()?.removeAllRanges?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedTaskIndexes.value = getTaskSelectionRange(taskSelectionDrag.startIndex, index)
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveTaskSelectionFromPointer(event) {
|
||||||
|
if (!taskSelectionDrag) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskRow = document.elementFromPoint(event.clientX, event.clientY)?.closest('[data-task-index]')
|
||||||
|
const index = Number(taskRow?.dataset.taskIndex)
|
||||||
|
|
||||||
|
if (Number.isInteger(index)) {
|
||||||
|
moveTaskSelection(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopTaskSelection() {
|
||||||
|
if (taskSelectionDrag && !taskSelectionDrag.isSelecting) {
|
||||||
|
selectedTaskIndexes.value = new Set()
|
||||||
|
}
|
||||||
|
|
||||||
|
taskSelectionDrag = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelectedTasks(event) {
|
||||||
|
if (selectedTaskIndexes.value.size === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault()
|
||||||
|
clearTaskSelection()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['Backspace', 'Delete'].includes(event.key)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
emit('clear:tasks', [...selectedTaskIndexes.value])
|
||||||
|
clearTaskSelection()
|
||||||
|
}
|
||||||
|
|
||||||
window.addEventListener('pointerup', stopTimetableDrag)
|
window.addEventListener('pointerup', stopTimetableDrag)
|
||||||
|
window.addEventListener('pointermove', moveTaskSelectionFromPointer)
|
||||||
|
window.addEventListener('pointerup', stopTaskSelection)
|
||||||
|
window.addEventListener('keydown', clearSelectedTasks)
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener('pointerup', stopTimetableDrag)
|
window.removeEventListener('pointerup', stopTimetableDrag)
|
||||||
|
window.removeEventListener('pointermove', moveTaskSelectionFromPointer)
|
||||||
|
window.removeEventListener('pointerup', stopTaskSelection)
|
||||||
|
window.removeEventListener('keydown', clearSelectedTasks)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -123,17 +231,23 @@ onBeforeUnmount(() => {
|
|||||||
>
|
>
|
||||||
<div class="planner-sheet__meta flex flex-col gap-4 py-3 sm:py-[18px]">
|
<div class="planner-sheet__meta flex flex-col gap-4 py-3 sm:py-[18px]">
|
||||||
<div class="planner-sheet__meta-top flex flex-col gap-3 sm:gap-4" :class="props.showDday ? 'sm:flex-row' : ''">
|
<div class="planner-sheet__meta-top flex flex-col gap-3 sm:gap-4" :class="props.showDday ? 'sm:flex-row' : ''">
|
||||||
<div class="relative min-h-[82px] border-t border-ink px-[10px] pt-[10px]" :class="props.showDday ? 'w-full sm:w-[394px] sm:flex-1' : 'w-full flex-1'">
|
<div class="planner-field min-h-[82px] px-[10px] pt-[10px]" :class="props.showDday ? 'w-full sm:w-[394px] sm:flex-1' : 'w-full flex-1'">
|
||||||
<span class="absolute -top-2 left-0 bg-paper px-[2px] text-muted">YEAR / MONTH / DAY</span>
|
<div class="planner-field__header flex items-center gap-2 text-muted">
|
||||||
<p class="pt-5 text-[11px] tracking-[0.2em] text-ink sm:pt-6 sm:text-sm">
|
<span class="shrink-0">YEAR / MONTH / DAY</span>
|
||||||
|
<span class="h-px flex-1 bg-ink"></span>
|
||||||
|
</div>
|
||||||
|
<p class="pt-6 text-[11px] tracking-[0.2em] text-ink sm:text-sm">
|
||||||
<span>{{ dateMain }}</span>
|
<span>{{ dateMain }}</span>
|
||||||
<span class="ml-1" :class="dateWeekdayTone">{{ dateWeekday }}</span>
|
<span class="ml-1" :class="dateWeekdayTone">{{ dateWeekday }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="props.showDday" class="relative min-h-[82px] w-full border-t border-ink px-[10px] pt-[10px] sm:w-[210px]">
|
<div v-if="props.showDday" class="planner-field min-h-[82px] w-full px-[10px] pt-[10px] sm:w-[210px]">
|
||||||
<span class="absolute -top-2 left-0 bg-paper px-[2px] text-muted">D-DAY</span>
|
<div class="planner-field__header flex items-center gap-2 text-muted">
|
||||||
|
<span class="shrink-0">D-DAY</span>
|
||||||
|
<span class="h-px flex-1 bg-ink"></span>
|
||||||
|
</div>
|
||||||
<p
|
<p
|
||||||
class="pt-5 text-[11px] tracking-[0.14em] text-ink sm:pt-6 sm:text-sm"
|
class="pt-6 text-[11px] tracking-[0.14em] text-ink sm:text-sm"
|
||||||
style="
|
style="
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 3;
|
-webkit-line-clamp: 3;
|
||||||
@@ -148,39 +262,61 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="planner-sheet__meta-bottom flex flex-col gap-3 border-b border-ink pb-3 sm:gap-4 sm:pb-[18px] lg:flex-row">
|
<div class="planner-sheet__meta-bottom flex flex-col gap-3 border-b border-ink pb-3 sm:gap-4 sm:pb-[18px] lg:flex-row">
|
||||||
<div class="relative min-h-[82px] w-full flex-1 border-t border-ink px-[10px] pt-[10px] lg:w-[394px]">
|
<div class="planner-field min-h-[82px] w-full flex-1 px-[10px] pt-[10px] lg:w-[394px]">
|
||||||
<span class="absolute -top-2 left-0 bg-paper px-[2px] text-muted">COMMENT</span>
|
<div class="planner-field__header flex items-center gap-2 text-muted">
|
||||||
|
<span class="shrink-0">COMMENT</span>
|
||||||
|
<span class="h-px flex-1 bg-ink"></span>
|
||||||
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
:value="comment"
|
:value="comment"
|
||||||
rows="3"
|
rows="3"
|
||||||
class="mt-3 h-[54px] w-full resize-none bg-transparent pt-2 text-[11px] font-semibold normal-case tracking-[0.06em] text-stone-700 outline-none placeholder:text-stone-400 sm:mt-4 sm:text-xs"
|
class="mt-3 h-[54px] w-full resize-none bg-transparent pt-2 text-[11px] font-semibold normal-case tracking-[0.06em] text-stone-700 outline-none placeholder:text-stone-400 sm:text-xs"
|
||||||
placeholder="오늘의 코멘트를 적어 주세요."
|
placeholder="오늘의 코멘트를 적어 주세요."
|
||||||
@input="emit('update:comment', $event.target.value)"
|
@input="emit('update:comment', $event.target.value)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative min-h-[82px] w-full border-t border-ink px-[10px] pt-[10px] lg:w-[210px]">
|
<div class="planner-field min-h-[82px] w-full px-[10px] pt-[10px] lg:w-[210px]">
|
||||||
<span class="absolute -top-2 left-0 bg-paper px-[2px] text-muted">총 시간</span>
|
<div class="planner-field__header flex items-center gap-2 text-muted">
|
||||||
<p class="pt-5 text-[11px] tracking-[0.2em] text-ink sm:pt-6 sm:text-sm">{{ totalTime }}</p>
|
<span class="shrink-0">FOCUSED TIME</span>
|
||||||
|
<span class="h-px flex-1 bg-ink"></span>
|
||||||
|
</div>
|
||||||
|
<p class="pt-6 text-[11px] tracking-[0.2em] text-ink sm:text-sm">{{ totalTime }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="planner-sheet__body flex flex-col gap-5 py-[10px] lg:flex-row lg:gap-4">
|
<div class="planner-sheet__body flex flex-col gap-5 py-[10px] lg:flex-row lg:gap-4">
|
||||||
<div class="planner-sheet__lists flex w-full flex-1 flex-col gap-7 sm:gap-9 lg:w-[394px]">
|
<div class="planner-sheet__lists flex w-full flex-1 flex-col gap-7 sm:gap-9 lg:w-[394px]">
|
||||||
<section class="relative">
|
<section>
|
||||||
<div class="absolute -top-[9px] left-0 bg-paper px-[2px] text-muted">TASKS</div>
|
<div class="flex items-center gap-2 text-muted">
|
||||||
<div class="border-t border-ink">
|
<span class="shrink-0">TASKS</span>
|
||||||
|
<span
|
||||||
|
v-if="selectedTaskIndexes.size > 0"
|
||||||
|
class="shrink-0 rounded-full border border-ink/30 px-2 py-[1px] text-[8px] tracking-[0.12em] text-ink"
|
||||||
|
>
|
||||||
|
선택 {{ selectedTaskIndexes.size }}개 · DEL 삭제 · ESC 취소
|
||||||
|
</span>
|
||||||
|
<span class="h-px flex-1 bg-ink"></span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
<div
|
<div
|
||||||
v-for="(task, index) in tasks"
|
v-for="(task, index) in tasks"
|
||||||
:key="task.id"
|
:key="task.id ?? index"
|
||||||
class="flex min-h-[38px] items-center border-b"
|
:data-task-index="index"
|
||||||
:class="index % 5 === 4 || index === tasks.length - 1 ? 'border-ink' : 'border-line'"
|
class="flex min-h-[38px] select-none items-center border-b transition-colors"
|
||||||
|
:class="[
|
||||||
|
index % 5 === 4 || index === tasks.length - 1 ? 'border-ink' : 'border-line',
|
||||||
|
isTaskSelected(index) ? 'bg-amber-100/55 ring-1 ring-inset ring-amber-500/40' : '',
|
||||||
|
]"
|
||||||
|
@pointerdown="startTaskSelection(index, $event)"
|
||||||
|
@pointerenter="moveTaskSelection(index)"
|
||||||
>
|
>
|
||||||
<div class="h-full w-[52px] shrink-0 border-r border-dashed border-ink px-1.5 py-[7px] sm:w-[62px] sm:px-2">
|
<div class="h-full w-[52px] shrink-0 border-r border-dashed border-ink px-1.5 py-[7px] sm:w-[62px] sm:px-2">
|
||||||
<input
|
<input
|
||||||
:value="task.label"
|
:value="task.label"
|
||||||
type="text"
|
type="text"
|
||||||
class="w-full bg-transparent text-center text-[9px] font-semibold tracking-[0.08em] text-stone-500 outline-none placeholder:text-stone-300"
|
class="w-full bg-transparent text-center text-[9px] font-semibold tracking-[0.08em] text-stone-500 outline-none placeholder:text-stone-300"
|
||||||
|
@focus="clearTaskSelectionOnFocus"
|
||||||
@input="emit('update:task-label', { index, value: $event.target.value })"
|
@input="emit('update:task-label', { index, value: $event.target.value })"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -190,13 +326,14 @@ onBeforeUnmount(() => {
|
|||||||
type="text"
|
type="text"
|
||||||
class="w-full truncate bg-transparent text-[10px] font-semibold normal-case tracking-[0.04em] text-stone-800 outline-none placeholder:text-stone-400 sm:text-[11px] sm:tracking-[0.06em]"
|
class="w-full truncate bg-transparent text-[10px] font-semibold normal-case tracking-[0.04em] text-stone-800 outline-none placeholder:text-stone-400 sm:text-[11px] sm:tracking-[0.06em]"
|
||||||
:placeholder="shouldShowTaskPlaceholder(index) ? '할 일을 입력해 주세요.' : ''"
|
:placeholder="shouldShowTaskPlaceholder(index) ? '할 일을 입력해 주세요.' : ''"
|
||||||
|
@focus="clearTaskSelectionOnFocus"
|
||||||
@input="emit('update:task-title', { index, value: $event.target.value })"
|
@input="emit('update:task-title', { index, value: $event.target.value })"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex h-full w-[36px] shrink-0 items-center justify-center p-[8px] sm:w-[42px] sm:p-[10px]">
|
<div class="flex h-full w-[36px] shrink-0 items-center justify-center p-[8px] sm:w-[42px] sm:p-[10px]">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex h-full w-full items-center justify-center border border-dashed transition"
|
class="flex h-full w-full items-center justify-center border border-dashed transition focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper"
|
||||||
:class="task.checked ? 'border-ink bg-stone-100 text-ink' : 'border-ink/60 text-transparent'"
|
:class="task.checked ? 'border-ink bg-stone-100 text-ink' : 'border-ink/60 text-transparent'"
|
||||||
@click="emit('toggle:task', index)"
|
@click="emit('toggle:task', index)"
|
||||||
>
|
>
|
||||||
@@ -207,9 +344,12 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="relative">
|
<section>
|
||||||
<div class="absolute -top-2 left-0 bg-paper px-[2px] text-muted">MEMO</div>
|
<div class="flex items-center gap-2 text-muted">
|
||||||
<div class="border-t border-ink">
|
<span class="shrink-0">MEMO</span>
|
||||||
|
<span class="h-px flex-1 bg-ink"></span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
<div
|
<div
|
||||||
v-for="(memoItem, index) in memo"
|
v-for="(memoItem, index) in memo"
|
||||||
:key="`memo-${index}`"
|
:key="`memo-${index}`"
|
||||||
@@ -237,10 +377,13 @@ onBeforeUnmount(() => {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="planner-sheet__timetable relative w-full shrink-0 lg:w-[210px]">
|
<section class="planner-sheet__timetable w-full shrink-0 lg:w-[210px]">
|
||||||
<div class="absolute -top-2 left-0 bg-paper px-[2px] text-muted">TIME TABLE</div>
|
<div class="flex items-center gap-2 text-muted">
|
||||||
|
<span class="shrink-0">TIME TABLE</span>
|
||||||
|
<span class="h-px flex-1 bg-ink"></span>
|
||||||
|
</div>
|
||||||
<div class="planner-sheet__timetable-scroll overflow-x-auto pb-1">
|
<div class="planner-sheet__timetable-scroll overflow-x-auto pb-1">
|
||||||
<div class="planner-sheet__timetable-grid min-w-[210px] border-t border-ink">
|
<div class="planner-sheet__timetable-grid min-w-[210px]">
|
||||||
<div
|
<div
|
||||||
v-for="(hour, index) in hours"
|
v-for="(hour, index) in hours"
|
||||||
:key="`${hour}-${index}`"
|
:key="`${hour}-${index}`"
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
|
guideTooltipResetMessage: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
@@ -37,6 +41,7 @@ const emit = defineEmits([
|
|||||||
'update:password-field',
|
'update:password-field',
|
||||||
'submit:profile',
|
'submit:profile',
|
||||||
'submit:password',
|
'submit:password',
|
||||||
|
'reset-guide-tooltips',
|
||||||
])
|
])
|
||||||
|
|
||||||
const initials = computed(() =>
|
const initials = computed(() =>
|
||||||
@@ -77,6 +82,26 @@ function updatePasswordField(field, event) {
|
|||||||
...
|
...
|
||||||
</p>
|
</p>
|
||||||
</div> -->
|
</div> -->
|
||||||
|
|
||||||
|
<div class="mt-6 rounded-[24px] border border-stone-200 bg-white/80 p-4">
|
||||||
|
<p class="text-[10px] font-bold tracking-[0.18em] text-stone-500">GUIDE TOOLTIPS</p>
|
||||||
|
<p class="mt-3 text-sm font-semibold leading-6 text-stone-700">
|
||||||
|
숨긴 가이드 툴팁을 다시 표시합니다.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mt-4 rounded-full border border-stone-900 px-4 py-3 text-xs font-bold tracking-[0.14em] text-stone-900 transition hover:bg-stone-900 hover:text-white"
|
||||||
|
@click="emit('reset-guide-tooltips')"
|
||||||
|
>
|
||||||
|
가이드 다시 보기
|
||||||
|
</button>
|
||||||
|
<p
|
||||||
|
v-if="guideTooltipResetMessage"
|
||||||
|
class="mt-3 rounded-2xl border border-stone-200 bg-[#fbf7f0] px-4 py-3 text-xs font-semibold leading-5 text-stone-600"
|
||||||
|
>
|
||||||
|
{{ guideTooltipResetMessage }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div class="grid gap-6">
|
<div class="grid gap-6">
|
||||||
|
|||||||
@@ -165,6 +165,35 @@
|
|||||||
.planner-sheet__meta-top > div,
|
.planner-sheet__meta-top > div,
|
||||||
.planner-sheet__meta-bottom > div {
|
.planner-sheet__meta-bottom > div {
|
||||||
min-height: 78px !important;
|
min-height: 78px !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planner-sheet__meta-bottom > div:first-child {
|
||||||
|
width: auto !important;
|
||||||
|
max-width: none !important;
|
||||||
|
flex: 1 1 auto !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planner-sheet__meta-bottom > div:last-child {
|
||||||
|
width: 210px !important;
|
||||||
|
max-width: 210px !important;
|
||||||
|
flex: 0 0 210px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planner-field__header {
|
||||||
|
width: 100% !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planner-field__header > span:first-child {
|
||||||
|
white-space: nowrap !important;
|
||||||
|
letter-spacing: 0.16em !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planner-sheet__meta-bottom > div:last-child > p {
|
||||||
|
padding-top: 24px !important;
|
||||||
|
white-space: nowrap !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.planner-sheet__lists {
|
.planner-sheet__lists {
|
||||||
@@ -187,11 +216,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.planner-sheet textarea {
|
.planner-sheet textarea {
|
||||||
height: auto !important;
|
height: 58px !important;
|
||||||
min-height: 48px !important;
|
min-height: 58px !important;
|
||||||
overflow: visible !important;
|
max-height: 58px !important;
|
||||||
|
overflow: hidden !important;
|
||||||
padding-top: 4px !important;
|
padding-top: 4px !important;
|
||||||
line-height: 1.55 !important;
|
line-height: 1.55 !important;
|
||||||
|
white-space: pre-wrap !important;
|
||||||
|
word-break: break-word !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.print-paper--double .print-sheet-frame {
|
.print-paper--double .print-sheet-frame {
|
||||||
|
|||||||
Reference in New Issue
Block a user