Compare commits
11 Commits
v0.1.42
...
b80994d114
| Author | SHA1 | Date | |
|---|---|---|---|
| b80994d114 | |||
| 6c69658d33 | |||
| bebd8ed8a6 | |||
| 6d9aa2c002 | |||
| 33ba9a2ab1 | |||
| ff20e90768 | |||
| 744202077f | |||
| e847ddd227 | |||
| 1f2d9ddc54 | |||
| 44932f9724 | |||
| 4a80721824 |
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/
|
||||
.DS_Store
|
||||
*.log
|
||||
.env
|
||||
.env.dev
|
||||
backend/.env
|
||||
backend/data/
|
||||
|
||||
34
HANDOFF.md
34
HANDOFF.md
@@ -4,7 +4,7 @@
|
||||
|
||||
- 프로젝트명: 10 Minute Planner 웹 UI
|
||||
- 기술 스택: Vue 3 + Vite + TailwindCSS + JavaScript
|
||||
- 현재 기준 버전: `v0.1.41` 준비 중
|
||||
- 현재 기준 버전: `v0.1.42` 준비 중
|
||||
- Git 원격 저장소: `https://git.sori.studio/zenn/planner.sori.studio.git`
|
||||
|
||||
## 기준 디자인
|
||||
@@ -201,15 +201,41 @@
|
||||
- 플래너 본문 D-DAY 텍스트는 3줄까지만 보이고, 넘치면 말줄임 처리되도록 정리했다.
|
||||
- 목표가 없는 빈 날짜에서는 `D-DAY 사용` 토글이 저장 상태와 무관하게 `OFF + 비활성`처럼 보이도록 보정했다.
|
||||
- 관리자 전용 `ADMIN` 메뉴와 기본 대시보드가 추가되었다. 현재는 사용자 수, 최근 접속, 문서 수, 목표 수, 계정별 최종 접속일을 읽기 전용으로 확인할 수 있다.
|
||||
- `users` 테이블에 `role`, `last_login_at` 컬럼이 추가되었다.
|
||||
- 관리자 이메일은 현재 `ADMIN_EMAILS` 환경변수로 판별한다. 기본값은 `zenn.message@gmail.com`이며, 쉼표로 여러 이메일을 넣을 수 있다.
|
||||
- `users` 테이블에 `login_id`, `role`, `last_login_at` 컬럼이 추가되었다.
|
||||
- 관리자 계정은 이제 이메일이 아니라 별도 자동 생성 계정으로 관리한다.
|
||||
- 관리자 계정은 서버 시작 시 `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` 실검증은 하지 못했고, 데몬 시작 후 다시 확인이 필요하다.
|
||||
- 이미지 저장 기능은 추후 `print-only` 또는 별도 export 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다.
|
||||
- Docker Compose는 프론트엔드와 백엔드를 함께 올리는 기준으로 설계하되, NAS 환경에 맞는 볼륨과 재시작 정책도 함께 고려한다.
|
||||
- 플래너 본문 라벨은 더 이상 `bg-paper` 배경으로 선을 덮지 않는다. `라벨 + 오른쪽 선` 구조로 바꿔 화면과 인쇄에서 노란 배경이 튀지 않도록 정리했다.
|
||||
- 날짜에 적용되는 목표가 새로 생기면 D-DAY는 기본 표시된다. 사용자가 해당 날짜에서 직접 `D-DAY 사용`을 끈 경우에만 로컬 숨김 목록에 저장해 다시 숨긴다.
|
||||
- 비로그인 랜딩은 모바일에서 `카드 안 카드`처럼 보이지 않도록 기능 설명 카드를 얇은 리스트로 단순화했고, `LOGIN` / `SIGN UP` 버튼은 같은 너비와 높이로 맞췄다. 로그인/회원가입 모달도 하단 전환 영역을 별도 카드 대신 구분선 형태로 정리했다.
|
||||
- 로그인 모달에 `로그인 유지` 체크박스를 추가했다. 기본값은 OFF이며, OFF 상태에서는 인증 토큰을 `sessionStorage`에 저장해 브라우저 세션이 끝나면 사라지고, ON 상태에서만 `localStorage`에 저장한다.
|
||||
- 현재 로그아웃은 프론트 저장 토큰을 지우는 수준이다. 개인 기록 서비스 성격을 고려하면 다음 단계에서 서버 세션 폐기 API와 미사용 자동 로그아웃 옵션을 추가하는 편이 좋다.
|
||||
- 비로그인 랜딩에 `DEMO VIEW`를 추가했다. 데모는 실제 저장/로그인 상태와 분리된 읽기 전용 샘플이며, 어제/오늘/내일 3일치 플래너를 전환해서 제품 감각을 먼저 볼 수 있다.
|
||||
- 플래너 본문 `MEMO`와 `TIME TABLE` 하단 높이를 맞추기 위해 TASK/MEMO 리스트 간격과 행 높이를 조정했다. TASK 드래그 선택 피드백은 레이아웃 흔들림을 줄이도록 ring 대신 배경색만 사용한다.
|
||||
- 이월된 할 일은 `carryoverFrom` 날짜를 가진다. TASK 본문에는 `이월` 배지를 표시하고, 클릭하면 오른쪽 `READ NEXT` 영역에 원래 시작 날짜를 안내한다.
|
||||
- 이월된 할 일을 완료할 때는 이전 날짜의 같은 이월 항목까지 모두 체크할지, 현재 날짜만 체크할지 선택한다. 기본값은 `항상 물어보기`이며, SETTINGS의 `CARRYOVER CHECK`에서 `항상 이전까지 체크` / `항상 오늘만 체크`로 바꿀 수 있다.
|
||||
- 오른쪽 플래너 사이드바의 중복 `STATS` 카드는 제거했다. 미완료 항목 이월 버튼은 `READ NEXT` 카드 아래로 이동했다.
|
||||
- 통계 화면은 진입 시 END DATE를 오늘로 보정한다. `최근 1주`, `최근 1달` 빠른 선택을 추가했고, 기존 `WEEKLY FLOW`는 선택 범위 안에서 기록이 있는 날짜별 집중 흐름을 보여주는 `RANGE FLOW`로 이름과 라벨을 정리했다.
|
||||
- `BEST DAY`는 선택 기간 안에서 집중 시간이 가장 긴 날짜를 고르고, `RECENT RECORDS`는 선택 기간 안의 기록을 날짜 내림차순으로 최대 5개 보여준다.
|
||||
- `CARRYOVER TASK` 선택 모달은 ESC로 닫힌다. 이월 배지의 시작일 안내는 오른쪽 패널 메시지 대신 배지 옆 팝업으로 표시한다.
|
||||
- 통계의 `BEST DAY`, `RECENT RECORDS` 기준 설명은 본문 문장 대신 물음표 가이드 팝업으로 제공한다.
|
||||
|
||||
## 갱신 규칙
|
||||
|
||||
|
||||
10
README.md
10
README.md
@@ -17,12 +17,14 @@ NAS나 서버에서 처음 올리는 경우 흐름은 아래처럼 생각하면
|
||||
|
||||
```bash
|
||||
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
|
||||
cp .env.example .env
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
처음 한 번은 이미지 빌드 때문에 시간이 걸릴 수 있다.
|
||||
실행 전에 `.env`의 DB와 관리자 계정 환경변수는 운영자만 아는 값으로 반드시 바꾼다.
|
||||
|
||||
## 초보자용 빠른 실행
|
||||
|
||||
@@ -41,6 +43,8 @@ cd planner.sori.studio
|
||||
실제 동작 확인이나 NAS 상시 실행은 아래 명령으로 시작한다.
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# .env에서 POSTGRES_*, DATABASE_URL, ADMIN_ACCOUNT_* 값을 비공개 운영 값으로 수정한다.
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
@@ -55,6 +59,10 @@ docker compose up -d --build
|
||||
- 프론트엔드: `http://NAS주소:48081`
|
||||
- PostgreSQL: `NAS주소:45432`
|
||||
|
||||
관리자 계정은 백엔드 시작 시 `.env`의 `ADMIN_ACCOUNT_*` 값으로 자동 생성된다.
|
||||
관리자 아이디와 비밀번호는 저장소 문서에 적지 말고, 운영자만 접근 가능한 비공개 환경변수 파일에서 관리한다.
|
||||
일반 사용자는 기존처럼 회원가입 후 이메일로 로그인하면 된다.
|
||||
|
||||
현재 `docker-compose.yml` 기준 내부 구성:
|
||||
|
||||
- 프론트엔드 nginx
|
||||
|
||||
10
TODO.md
10
TODO.md
@@ -89,9 +89,18 @@
|
||||
- [x] UGREEN NAS 기준 `docker-compose.yml` 초안을 작성한다.
|
||||
- [x] 백엔드 기본 스캐폴딩을 추가한다.
|
||||
- [x] PostgreSQL 전환 초안을 적용한다.
|
||||
- [x] 로그인 화면 문구와 관리자 정보 노출 지점을 일반 사용자 기준으로 정리한다.
|
||||
- [x] 비로그인 사용자가 저장 없이 볼 수 있는 3일치 샘플 데모 화면을 추가한다.
|
||||
- [x] 미완료 항목을 다음 날짜 빈칸으로 이월하는 버튼을 추가한다.
|
||||
- [x] 이월된 할 일에 배지를 표시하고 원래 시작 날짜를 확인할 수 있게 한다.
|
||||
- [x] 이월된 할 일을 체크할 때 이전 날짜까지 함께 완료할지 선택하는 정책을 추가한다.
|
||||
- [ ] 이메일 인증 플로우를 설계하고 구현한다.
|
||||
- [ ] 비밀번호 찾기 / 재설정 토큰 흐름을 추가한다.
|
||||
- [ ] 로그인 및 인증 관련 rate limit / 잠금 정책을 추가한다.
|
||||
- [x] 로그인 유지 여부를 사용자가 선택할 수 있게 한다.
|
||||
- [ ] 일정 시간 미사용 시 자동 로그아웃 옵션을 추가한다.
|
||||
- [ ] 설정 화면에서 현재 기기 로그인 상태와 저장 방식을 안내한다.
|
||||
- [ ] 서버 세션을 명시적으로 폐기하는 로그아웃 API를 추가한다.
|
||||
- [ ] 메일 발송 인프라와 발신 도메인 정책을 확정한다.
|
||||
- [ ] Resend 무료 플랜 대체 수단으로 SES 또는 일반 SMTP 연동 방식을 확정한다.
|
||||
- [ ] 관리자 페이지에서 계정 비활성화 / 강제 로그아웃 / 삭제 기능을 추가한다.
|
||||
@@ -129,3 +138,4 @@
|
||||
- 구현할 때마다 완료된 항목은 체크하고, 큰 결정사항은 `HANDOFF.md`에도 함께 반영한다.
|
||||
- Resend 무료 플랜은 도메인 수 제약이 있으므로, 실제 인증 메일은 AWS SES 또는 별도 SMTP 서비스 전환을 전제로 설계하는 편이 안전하다.
|
||||
- 관리자 기능은 읽기 전용 대시보드부터 시작하고, 실제 정리 액션은 권한/감사 로그 정책을 정한 뒤 추가하는 편이 안전하다.
|
||||
- 관리자 아이디/비밀번호는 README나 HANDOFF에 실제 값으로 남기지 않고, Docker 배포용 비공개 `.env`에서만 관리한다.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
PORT=3001
|
||||
DATABASE_URL=postgresql://planner:planner1234@localhost:5432/ten_minute_planner
|
||||
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,7 +14,10 @@ const envSchema = z.object({
|
||||
CORS_ORIGIN: z.string().default('http://localhost:5173'),
|
||||
SESSION_TTL_DAYS: z.coerce.number().default(30),
|
||||
APP_BASE_URL: z.string().default('http://localhost:5173'),
|
||||
ADMIN_EMAILS: z.string().default('zenn.message@gmail.com'),
|
||||
ADMIN_ACCOUNT_ID: z.string().min(1),
|
||||
ADMIN_ACCOUNT_PASSWORD: z.string().min(12),
|
||||
ADMIN_ACCOUNT_EMAIL: z.string().email(),
|
||||
ADMIN_ACCOUNT_NICKNAME: z.string().min(1),
|
||||
})
|
||||
|
||||
export const env = envSchema.parse(process.env)
|
||||
|
||||
@@ -5,6 +5,7 @@ export async function ensureDatabaseSchema() {
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
login_id VARCHAR(60) UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
nickname VARCHAR(60) NOT NULL,
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'user',
|
||||
@@ -14,6 +15,9 @@ export async function ensureDatabaseSchema() {
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS login_id VARCHAR(60);
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS role VARCHAR(20) NOT NULL DEFAULT 'user';
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
export const users = pgTable('users', {
|
||||
id: serial('id').primaryKey(),
|
||||
email: varchar('email', { length: 255 }).notNull().unique(),
|
||||
loginId: varchar('login_id', { length: 60 }).unique(),
|
||||
passwordHash: varchar('password_hash', { length: 255 }).notNull(),
|
||||
nickname: varchar('nickname', { length: 60 }).notNull(),
|
||||
role: varchar('role', { length: 20 }).notNull().default('user'),
|
||||
|
||||
69
backend/src/lib/adminAccount.js
Normal file
69
backend/src/lib/adminAccount.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import { and, eq, isNull, ne, or } from 'drizzle-orm'
|
||||
import { env } from '../config.js'
|
||||
import { db } from '../db/client.js'
|
||||
import { users } from '../db/schema.js'
|
||||
import { hashPassword } from './password.js'
|
||||
|
||||
export async function ensureAdminAccount() {
|
||||
const loginId = env.ADMIN_ACCOUNT_ID.trim()
|
||||
const email = env.ADMIN_ACCOUNT_EMAIL.trim().toLowerCase()
|
||||
const nickname = env.ADMIN_ACCOUNT_NICKNAME.trim()
|
||||
const password = env.ADMIN_ACCOUNT_PASSWORD
|
||||
|
||||
const now = new Date()
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
role: 'user',
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(users.role, 'admin'),
|
||||
or(
|
||||
ne(users.loginId, loginId),
|
||||
isNull(users.loginId),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const [existingAdmin] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(
|
||||
or(
|
||||
eq(users.loginId, loginId),
|
||||
eq(users.email, email),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingAdmin) {
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
email,
|
||||
nickname,
|
||||
role: 'admin',
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(users.id, existingAdmin.id))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password)
|
||||
|
||||
await db.insert(users).values({
|
||||
email,
|
||||
loginId,
|
||||
passwordHash,
|
||||
nickname,
|
||||
role: 'admin',
|
||||
emailVerifiedAt: now,
|
||||
lastLoginAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
@@ -33,11 +33,11 @@ export async function registerAdminRoutes(app) {
|
||||
|
||||
const [summary] = await db
|
||||
.select({
|
||||
totalUsers: sql<number>`count(*)::int`,
|
||||
totalAdmins: sql<number>`count(*) filter (where ${users.role} = 'admin')::int`,
|
||||
verifiedUsers: sql<number>`count(*) filter (where ${users.emailVerifiedAt} is not null)::int`,
|
||||
activeUsers30d: sql<number>`count(*) filter (where ${users.lastLoginAt} >= now() - interval '30 days')::int`,
|
||||
newUsers7d: sql<number>`count(*) filter (where ${users.createdAt} >= now() - interval '7 days')::int`,
|
||||
totalUsers: sql`count(*)::int`,
|
||||
totalAdmins: sql`count(*) filter (where ${users.role} = 'admin')::int`,
|
||||
verifiedUsers: sql`count(*) filter (where ${users.emailVerifiedAt} is not null)::int`,
|
||||
activeUsers30d: sql`count(*) filter (where ${users.lastLoginAt} >= now() - interval '30 days')::int`,
|
||||
newUsers7d: sql`count(*) filter (where ${users.createdAt} >= now() - interval '7 days')::int`,
|
||||
})
|
||||
.from(users)
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { and, eq, gt, isNull } from 'drizzle-orm'
|
||||
import { env } from '../config.js'
|
||||
import { and, eq, gt, isNull, or } from 'drizzle-orm'
|
||||
import { z } from 'zod'
|
||||
import { db } from '../db/client.js'
|
||||
import { authSessions, emailVerificationTokens, passwordResetTokens, users } from '../db/schema.js'
|
||||
import { createSessionToken, hashSessionToken, hashPassword, verifyPassword } from '../lib/password.js'
|
||||
import { createSession, findAuthenticatedUser } from '../lib/authSession.js'
|
||||
import { env } from '../config.js'
|
||||
|
||||
const signupSchema = z.object({
|
||||
email: z.string().trim().email(),
|
||||
@@ -13,7 +13,7 @@ const signupSchema = z.object({
|
||||
})
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().trim().email(),
|
||||
email: z.string().trim().min(1).max(255),
|
||||
password: z.string().min(1).max(72),
|
||||
})
|
||||
|
||||
@@ -45,15 +45,6 @@ const passwordResetConfirmSchema = z.object({
|
||||
})
|
||||
|
||||
const TOKEN_TTL_MS = 1000 * 60 * 30
|
||||
const adminEmails = new Set(
|
||||
env.ADMIN_EMAILS.split(',')
|
||||
.map((email) => email.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
)
|
||||
|
||||
function resolveUserRole(email) {
|
||||
return adminEmails.has(email.toLowerCase()) ? 'admin' : 'user'
|
||||
}
|
||||
|
||||
function buildPreviewUrl(pathname, token) {
|
||||
const url = new URL(pathname, env.APP_BASE_URL)
|
||||
@@ -107,6 +98,7 @@ function sanitizeUser(user) {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
loginId: user.loginId,
|
||||
nickname: user.nickname,
|
||||
role: user.role,
|
||||
emailVerifiedAt: user.emailVerifiedAt,
|
||||
@@ -144,15 +136,14 @@ export async function registerAuthRoutes(app) {
|
||||
|
||||
const now = new Date()
|
||||
const passwordHash = await hashPassword(password)
|
||||
const role = resolveUserRole(normalizedEmail)
|
||||
|
||||
const [user] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
email: normalizedEmail,
|
||||
loginId: null,
|
||||
passwordHash,
|
||||
nickname,
|
||||
role,
|
||||
role: 'user',
|
||||
emailVerifiedAt: null,
|
||||
lastLoginAt: now,
|
||||
createdAt: now,
|
||||
@@ -181,17 +172,23 @@ export async function registerAuthRoutes(app) {
|
||||
})
|
||||
}
|
||||
|
||||
const normalizedEmail = payload.data.email.toLowerCase()
|
||||
const identifier = payload.data.email.trim()
|
||||
const normalizedEmail = identifier.toLowerCase()
|
||||
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, normalizedEmail))
|
||||
.where(
|
||||
or(
|
||||
eq(users.email, normalizedEmail),
|
||||
eq(users.loginId, identifier),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!user) {
|
||||
return reply.code(401).send({
|
||||
message: '이메일 또는 비밀번호가 올바르지 않습니다.',
|
||||
message: '이메일, 아이디 또는 비밀번호가 올바르지 않습니다.',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -199,17 +196,15 @@ export async function registerAuthRoutes(app) {
|
||||
|
||||
if (!passwordMatches) {
|
||||
return reply.code(401).send({
|
||||
message: '이메일 또는 비밀번호가 올바르지 않습니다.',
|
||||
message: '이메일, 아이디 또는 비밀번호가 올바르지 않습니다.',
|
||||
})
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const role = resolveUserRole(user.email)
|
||||
|
||||
const [updatedUser] = await db
|
||||
.update(users)
|
||||
.set({
|
||||
role,
|
||||
lastLoginAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
@@ -234,23 +229,6 @@ export async function registerAuthRoutes(app) {
|
||||
})
|
||||
}
|
||||
|
||||
const resolvedRole = resolveUserRole(user.email)
|
||||
|
||||
if (user.role !== resolvedRole) {
|
||||
const [updatedUser] = await db
|
||||
.update(users)
|
||||
.set({
|
||||
role: resolvedRole,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(users.id, user.id))
|
||||
.returning()
|
||||
|
||||
return {
|
||||
user: sanitizeUser(updatedUser),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
user: sanitizeUser(user),
|
||||
}
|
||||
@@ -293,7 +271,6 @@ export async function registerAuthRoutes(app) {
|
||||
.set({
|
||||
email: normalizedEmail,
|
||||
nickname: payload.data.nickname,
|
||||
role: resolveUserRole(normalizedEmail),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(users.id, user.id))
|
||||
|
||||
@@ -3,6 +3,7 @@ import cors from '@fastify/cors'
|
||||
import { env } from './config.js'
|
||||
import { pool } from './db/client.js'
|
||||
import { ensureDatabaseSchema } from './db/init.js'
|
||||
import { ensureAdminAccount } from './lib/adminAccount.js'
|
||||
import { registerAuthRoutes } from './routes/auth.js'
|
||||
import { registerAdminRoutes } from './routes/admin.js'
|
||||
import { registerGoalRoutes } from './routes/goals.js'
|
||||
@@ -13,6 +14,7 @@ const app = Fastify({
|
||||
})
|
||||
|
||||
await ensureDatabaseSchema()
|
||||
await ensureAdminAccount()
|
||||
|
||||
await app.register(cors, {
|
||||
origin: env.CORS_ORIGIN,
|
||||
|
||||
@@ -2,16 +2,14 @@ services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: ten-minute-postgres-dev
|
||||
environment:
|
||||
POSTGRES_DB: ten_minute_planner
|
||||
POSTGRES_USER: planner
|
||||
POSTGRES_PASSWORD: planner1234
|
||||
env_file:
|
||||
- ./.env.dev
|
||||
volumes:
|
||||
- postgres_dev_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
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
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
@@ -22,9 +20,10 @@ services:
|
||||
container_name: ten-minute-backend-dev
|
||||
working_dir: /app
|
||||
command: sh -c "npm install && npm run dev"
|
||||
env_file:
|
||||
- ./.env.dev
|
||||
environment:
|
||||
PORT: 3001
|
||||
DATABASE_URL: postgresql://planner:planner1234@postgres:5432/ten_minute_planner
|
||||
CORS_ORIGIN: http://localhost:5173
|
||||
SESSION_TTL_DAYS: 30
|
||||
volumes:
|
||||
|
||||
@@ -2,16 +2,14 @@ services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: ten-minute-postgres
|
||||
environment:
|
||||
POSTGRES_DB: ten_minute_planner
|
||||
POSTGRES_USER: zenn
|
||||
POSTGRES_PASSWORD: wps!vmffosj180204
|
||||
env_file:
|
||||
- ./.env
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "45432:5432"
|
||||
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
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
@@ -21,9 +19,10 @@ services:
|
||||
build:
|
||||
context: ./backend
|
||||
container_name: ten-minute-backend
|
||||
env_file:
|
||||
- ./.env
|
||||
environment:
|
||||
PORT: 3001
|
||||
DATABASE_URL: postgresql://zenn:wps%21vmffosj180204@postgres:5432/ten_minute_planner
|
||||
CORS_ORIGIN: http://localhost:48081
|
||||
SESSION_TTL_DAYS: 30
|
||||
depends_on:
|
||||
@@ -46,4 +45,4 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
postgres_data:
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ten-minute-planner",
|
||||
"version": "0.1.41",
|
||||
"version": "0.1.42",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ten-minute-planner",
|
||||
"version": "0.1.41",
|
||||
"version": "0.1.42",
|
||||
"dependencies": {
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ten-minute-planner",
|
||||
"private": true,
|
||||
"version": "0.1.41",
|
||||
"version": "0.1.42",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
1006
src/App.vue
1006
src/App.vue
File diff suppressed because it is too large
Load Diff
@@ -32,7 +32,7 @@ const emit = defineEmits([
|
||||
function updateField(field, event) {
|
||||
emit('update:field', {
|
||||
field,
|
||||
value: event.target.value,
|
||||
value: event.target.type === 'checkbox' ? event.target.checked : event.target.value,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -40,17 +40,17 @@ function updateField(field, event) {
|
||||
<template>
|
||||
<div
|
||||
v-if="open"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-stone-900/45 px-4 py-8 backdrop-blur-sm"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-stone-900/45 px-4 py-6 backdrop-blur-sm"
|
||||
>
|
||||
<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-[420px] rounded-[26px] border border-white/70 bg-[#f7f2ea] p-5 shadow-[0_24px_80px_rgba(28,25,23,0.2)] sm:p-6">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="space-y-2">
|
||||
<p class="text-[11px] font-bold uppercase tracking-[0.24em] text-stone-500">Account</p>
|
||||
<h2 class="text-2xl font-semibold tracking-[-0.04em] text-stone-900">
|
||||
<div>
|
||||
<p class="text-[10px] font-bold uppercase tracking-[0.24em] text-stone-500">10 Minute Planner</p>
|
||||
<h2 class="mt-2 text-2xl font-semibold tracking-[-0.04em] text-stone-900">
|
||||
{{ mode === 'login' ? '로그인' : '회원가입' }}
|
||||
</h2>
|
||||
<p class="text-sm leading-6 text-stone-600">
|
||||
{{ mode === 'login' ? '저장된 플래너를 다시 이어서 볼 수 있습니다.' : '사용자별 기록과 통계를 연결하기 위한 계정을 만듭니다.' }}
|
||||
<p class="mt-2 text-sm leading-6 text-stone-600">
|
||||
{{ mode === 'login' ? '내 플래너를 이어서 열어요.' : '기록을 저장할 계정을 만들어요.' }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -68,19 +68,21 @@ function updateField(field, event) {
|
||||
<input
|
||||
:value="form.nickname"
|
||||
type="text"
|
||||
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/90 px-4 py-3 text-sm font-semibold text-stone-800 outline-none transition focus:border-stone-500"
|
||||
placeholder="닉네임을 입력해 주세요."
|
||||
@input="updateField('nickname', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-[11px] font-bold tracking-[0.16em] text-stone-600">이메일</label>
|
||||
<label class="text-[11px] font-bold tracking-[0.16em] text-stone-600">
|
||||
{{ mode === 'login' ? '이메일 또는 아이디' : '이메일' }}
|
||||
</label>
|
||||
<input
|
||||
:value="form.email"
|
||||
type="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"
|
||||
placeholder="zenn@example.com"
|
||||
:type="mode === 'login' ? 'text' : 'email'"
|
||||
class="w-full rounded-2xl border border-stone-300 bg-white/90 px-4 py-3 text-sm font-semibold text-stone-800 outline-none transition focus:border-stone-500"
|
||||
:placeholder="mode === 'login' ? '이메일 또는 아이디를 입력해 주세요.' : 'you@example.com'"
|
||||
@input="updateField('email', $event)"
|
||||
/>
|
||||
</div>
|
||||
@@ -90,12 +92,25 @@ function updateField(field, event) {
|
||||
<input
|
||||
:value="form.password"
|
||||
type="password"
|
||||
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/90 px-4 py-3 text-sm font-semibold text-stone-800 outline-none transition focus:border-stone-500"
|
||||
placeholder="8자 이상 입력해 주세요."
|
||||
@input="updateField('password', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label
|
||||
v-if="mode === 'login'"
|
||||
class="-mt-1 flex items-center gap-2 px-1 text-left"
|
||||
>
|
||||
<input
|
||||
:checked="form.rememberSession"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 shrink-0 accent-stone-900"
|
||||
@change="updateField('rememberSession', $event)"
|
||||
/>
|
||||
<span class="text-xs font-bold tracking-[0.08em] text-stone-700">로그인 상태 유지</span>
|
||||
</label>
|
||||
|
||||
<p
|
||||
v-if="message"
|
||||
class="rounded-2xl border border-stone-300 bg-white/80 px-4 py-3 text-sm font-semibold leading-6 text-stone-700"
|
||||
@@ -108,17 +123,17 @@ 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"
|
||||
:disabled="busy"
|
||||
>
|
||||
{{ busy ? '처리 중...' : mode === 'login' ? 'LOGIN' : 'SIGN UP' }}
|
||||
{{ busy ? '처리 중...' : mode === 'login' ? '로그인하기' : '가입하기' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between gap-3 rounded-2xl border border-stone-300 bg-white/70 px-4 py-3">
|
||||
<div class="mt-5 flex items-center justify-center gap-2 border-t border-stone-300/70 pt-4">
|
||||
<p class="text-sm font-semibold text-stone-600">
|
||||
{{ mode === 'login' ? '아직 계정이 없나요?' : '이미 계정이 있나요?' }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs font-bold tracking-[0.16em] text-stone-900 underline underline-offset-4"
|
||||
class="text-xs font-bold tracking-[0.14em] text-stone-900 underline underline-offset-4"
|
||||
@click="emit('switch-mode', mode === 'login' ? 'signup' : 'login')"
|
||||
>
|
||||
{{ mode === 'login' ? '회원가입' : '로그인' }}
|
||||
|
||||
99
src/components/GuideTooltip.vue
Normal file
99
src/components/GuideTooltip.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<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,
|
||||
},
|
||||
dismissible: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
buttonLabel: {
|
||||
type: String,
|
||||
default: '?',
|
||||
},
|
||||
})
|
||||
|
||||
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"
|
||||
>
|
||||
{{ buttonLabel }}
|
||||
</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
|
||||
v-if="dismissible"
|
||||
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,6 @@
|
||||
<script setup>
|
||||
import { onBeforeUnmount } from 'vue'
|
||||
import { onBeforeUnmount, ref } from 'vue'
|
||||
import GuideTooltip from './GuideTooltip.vue'
|
||||
|
||||
const props = defineProps({
|
||||
dateMain: {
|
||||
@@ -50,6 +51,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: 'SORI.STUDIO',
|
||||
},
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -57,12 +62,15 @@ const emit = defineEmits([
|
||||
'update:task-label',
|
||||
'update:task-title',
|
||||
'toggle:task',
|
||||
'clear:tasks',
|
||||
'update:memo-label',
|
||||
'update:memo',
|
||||
'update:timetable',
|
||||
])
|
||||
|
||||
let dragState = null
|
||||
let taskSelectionDrag = null
|
||||
const selectedTaskIndexes = ref(new Set())
|
||||
|
||||
function shouldShowTaskPlaceholder(index) {
|
||||
return index === 0 && props.tasks.every((task) => !task.title.trim())
|
||||
@@ -81,6 +89,10 @@ function buildTimedRange(baseTimetable, startIndex, endIndex, nextValue) {
|
||||
}
|
||||
|
||||
function startTimetableDrag(index, event) {
|
||||
if (props.readonly) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.button !== 0 && event.button !== 2) {
|
||||
return
|
||||
}
|
||||
@@ -97,7 +109,7 @@ function startTimetableDrag(index, event) {
|
||||
}
|
||||
|
||||
function moveTimetableDrag(index) {
|
||||
if (!dragState) {
|
||||
if (props.readonly || !dragState) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -111,9 +123,122 @@ function stopTimetableDrag() {
|
||||
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 (props.readonly) {
|
||||
return
|
||||
}
|
||||
|
||||
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 (props.readonly) {
|
||||
return
|
||||
}
|
||||
|
||||
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('pointermove', moveTaskSelectionFromPointer)
|
||||
window.addEventListener('pointerup', stopTaskSelection)
|
||||
window.addEventListener('keydown', clearSelectedTasks)
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('pointerup', stopTimetableDrag)
|
||||
window.removeEventListener('pointermove', moveTaskSelectionFromPointer)
|
||||
window.removeEventListener('pointerup', stopTaskSelection)
|
||||
window.removeEventListener('keydown', clearSelectedTasks)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -123,17 +248,23 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<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="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'">
|
||||
<span class="absolute -top-2 left-0 bg-paper px-[2px] text-muted">YEAR / MONTH / DAY</span>
|
||||
<p class="pt-5 text-[11px] tracking-[0.2em] text-ink sm:pt-6 sm:text-sm">
|
||||
<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'">
|
||||
<div class="planner-field__header flex items-center gap-2 text-muted">
|
||||
<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 class="ml-1" :class="dateWeekdayTone">{{ dateWeekday }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="props.showDday" class="relative min-h-[82px] w-full border-t border-ink px-[10px] pt-[10px] sm:w-[210px]">
|
||||
<span class="absolute -top-2 left-0 bg-paper px-[2px] text-muted">D-DAY</span>
|
||||
<div v-if="props.showDday" class="planner-field min-h-[82px] w-full px-[10px] pt-[10px] sm:w-[210px]">
|
||||
<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
|
||||
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="
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
@@ -148,55 +279,89 @@ onBeforeUnmount(() => {
|
||||
</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="relative min-h-[82px] w-full flex-1 border-t border-ink 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 min-h-[82px] w-full flex-1 px-[10px] pt-[10px] lg:w-[394px]">
|
||||
<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
|
||||
:value="comment"
|
||||
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"
|
||||
:readonly="props.readonly"
|
||||
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="오늘의 코멘트를 적어 주세요."
|
||||
@input="emit('update:comment', $event.target.value)"
|
||||
/>
|
||||
</div>
|
||||
<div class="relative min-h-[82px] w-full border-t border-ink px-[10px] pt-[10px] lg:w-[210px]">
|
||||
<span class="absolute -top-2 left-0 bg-paper px-[2px] text-muted">총 시간</span>
|
||||
<p class="pt-5 text-[11px] tracking-[0.2em] text-ink sm:pt-6 sm:text-sm">{{ totalTime }}</p>
|
||||
<div class="planner-field min-h-[82px] w-full px-[10px] pt-[10px] lg:w-[210px]">
|
||||
<div class="planner-field__header flex items-center gap-2 text-muted">
|
||||
<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 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]">
|
||||
<section class="relative">
|
||||
<div class="absolute -top-[9px] left-0 bg-paper px-[2px] text-muted">TASKS</div>
|
||||
<div class="border-t border-ink">
|
||||
<div class="planner-sheet__lists flex w-full flex-1 flex-col gap-[25px] lg:w-[394px]">
|
||||
<section>
|
||||
<div class="flex items-center gap-2 text-muted">
|
||||
<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
|
||||
v-for="(task, index) in tasks"
|
||||
:key="task.id"
|
||||
class="flex min-h-[38px] items-center border-b"
|
||||
:class="index % 5 === 4 || index === tasks.length - 1 ? 'border-ink' : 'border-line'"
|
||||
:key="task.id ?? index"
|
||||
:data-task-index="index"
|
||||
class="flex 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' : '',
|
||||
]"
|
||||
@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">
|
||||
<input
|
||||
:value="task.label"
|
||||
type="text"
|
||||
:readonly="props.readonly"
|
||||
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 })"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 items-center px-2 sm:px-3">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2 px-2 sm:px-3">
|
||||
<input
|
||||
:value="task.title"
|
||||
type="text"
|
||||
:readonly="props.readonly"
|
||||
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) ? '할 일을 입력해 주세요.' : ''"
|
||||
@focus="clearTaskSelectionOnFocus"
|
||||
@input="emit('update:task-title', { index, value: $event.target.value })"
|
||||
/>
|
||||
<GuideTooltip
|
||||
v-if="task.carryoverFrom"
|
||||
:title="'이월된 할 일'"
|
||||
:description="`이 항목은 ${task.carryoverFrom}부터 이월된 할 일입니다.`"
|
||||
:dismissible="false"
|
||||
button-label="이월"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex h-full w-[36px] shrink-0 items-center justify-center p-[8px] sm:w-[42px] sm:p-[10px]">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-full w-full items-center justify-center border border-dashed transition"
|
||||
:disabled="props.readonly"
|
||||
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'"
|
||||
@click="emit('toggle:task', index)"
|
||||
>
|
||||
@@ -207,19 +372,23 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="relative">
|
||||
<div class="absolute -top-2 left-0 bg-paper px-[2px] text-muted">MEMO</div>
|
||||
<div class="border-t border-ink">
|
||||
<section>
|
||||
<div class="flex items-center gap-2 text-muted">
|
||||
<span class="shrink-0">MEMO</span>
|
||||
<span class="h-px flex-1 bg-ink"></span>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
v-for="(memoItem, index) in memo"
|
||||
:key="`memo-${index}`"
|
||||
class="flex min-h-[38px] items-center border-b"
|
||||
class="flex h-[38px] items-center border-b"
|
||||
:class="index === memo.length - 1 ? 'border-ink' : 'border-line'"
|
||||
>
|
||||
<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
|
||||
:value="memoItem.label"
|
||||
type="text"
|
||||
:readonly="props.readonly"
|
||||
class="w-full bg-transparent text-center text-[9px] font-semibold tracking-[0.08em] text-stone-500 outline-none placeholder:text-stone-300"
|
||||
@input="emit('update:memo-label', { index, value: $event.target.value })"
|
||||
/>
|
||||
@@ -228,6 +397,7 @@ onBeforeUnmount(() => {
|
||||
<input
|
||||
:value="memoItem.text"
|
||||
type="text"
|
||||
:readonly="props.readonly"
|
||||
class="w-full bg-transparent text-[10px] font-semibold normal-case tracking-[0.04em] text-stone-700 outline-none placeholder:text-stone-400 sm:text-[11px] sm:tracking-[0.06em]"
|
||||
@input="emit('update:memo', { index, value: $event.target.value })"
|
||||
/>
|
||||
@@ -237,14 +407,17 @@ onBeforeUnmount(() => {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="planner-sheet__timetable relative w-full shrink-0 lg:w-[210px]">
|
||||
<div class="absolute -top-2 left-0 bg-paper px-[2px] text-muted">TIME TABLE</div>
|
||||
<section class="planner-sheet__timetable w-full shrink-0 lg:w-[210px]">
|
||||
<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-grid min-w-[210px] border-t border-ink">
|
||||
<div class="planner-sheet__timetable-grid min-w-[210px]">
|
||||
<div
|
||||
v-for="(hour, index) in hours"
|
||||
:key="`${hour}-${index}`"
|
||||
class="flex h-[26px] border-b sm:h-[30px]"
|
||||
class="flex h-[25px] border-b sm:h-[30px]"
|
||||
:class="index === hours.length - 1 ? 'border-ink' : 'border-line'"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -30,6 +30,14 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
guideTooltipResetMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
carryoverCheckPolicy: {
|
||||
type: String,
|
||||
default: 'ask',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -37,6 +45,8 @@ const emit = defineEmits([
|
||||
'update:password-field',
|
||||
'submit:profile',
|
||||
'submit:password',
|
||||
'reset-guide-tooltips',
|
||||
'update:carryover-check-policy',
|
||||
])
|
||||
|
||||
const initials = computed(() =>
|
||||
@@ -77,6 +87,49 @@ function updatePasswordField(field, event) {
|
||||
...
|
||||
</p>
|
||||
</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>
|
||||
|
||||
<div class="mt-4 rounded-[24px] border border-stone-200 bg-white/80 p-4">
|
||||
<p class="text-[10px] font-bold tracking-[0.18em] text-stone-500">CARRYOVER CHECK</p>
|
||||
<p class="mt-3 text-sm font-semibold leading-6 text-stone-700">
|
||||
이월된 할 일을 완료할 때 이전 날짜 항목까지 함께 체크할지 정합니다.
|
||||
</p>
|
||||
<div class="mt-4 grid gap-2">
|
||||
<button
|
||||
v-for="option in [
|
||||
{ value: 'ask', label: '항상 물어보기' },
|
||||
{ value: 'all', label: '항상 이전까지 체크' },
|
||||
{ value: 'current', label: '항상 오늘만 체크' },
|
||||
]"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="rounded-2xl border px-4 py-3 text-left text-xs font-bold tracking-[0.12em] transition"
|
||||
:class="carryoverCheckPolicy === option.value ? 'border-stone-900 bg-stone-900 text-white' : 'border-stone-200 bg-white text-stone-600 hover:border-stone-400 hover:text-stone-900'"
|
||||
@click="emit('update:carryover-check-policy', option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="grid gap-6">
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import GuideTooltip from './GuideTooltip.vue'
|
||||
|
||||
defineProps({
|
||||
overviewCards: {
|
||||
type: Array,
|
||||
@@ -30,18 +32,34 @@ defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:range-start', 'update:range-end'])
|
||||
const emit = defineEmits(['update:range-start', 'update:range-end', 'quick-range'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="grid gap-6">
|
||||
<article class="rounded-[28px] border border-white/60 bg-white/80 p-5 shadow-paper backdrop-blur">
|
||||
<article class="rounded-[28px] border border-stone-200 bg-white/86 p-5">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p class="text-[11px] font-bold tracking-[0.22em] text-stone-500">RANGE</p>
|
||||
<h2 class="mt-2 text-2xl font-semibold tracking-[-0.05em] text-stone-900">
|
||||
원하는 기간 기준으로 통계 보기
|
||||
</h2>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border border-stone-200 bg-white px-4 py-2 text-[11px] font-bold tracking-[0.12em] text-stone-600 transition hover:border-stone-500 hover:text-stone-900"
|
||||
@click="emit('quick-range', 7)"
|
||||
>
|
||||
최근 1주
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border border-stone-200 bg-white px-4 py-2 text-[11px] font-bold tracking-[0.12em] text-stone-600 transition hover:border-stone-500 hover:text-stone-900"
|
||||
@click="emit('quick-range', 30)"
|
||||
>
|
||||
최근 1달
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<label class="flex flex-col gap-2 text-[11px] font-bold tracking-[0.14em] text-stone-500">
|
||||
@@ -70,7 +88,7 @@ const emit = defineEmits(['update:range-start', 'update:range-end'])
|
||||
<article
|
||||
v-for="card in overviewCards"
|
||||
:key="card.label"
|
||||
class="rounded-[28px] border border-white/60 bg-white/80 p-5 shadow-paper backdrop-blur"
|
||||
class="rounded-[28px] border border-stone-200 bg-white/86 p-5"
|
||||
>
|
||||
<p class="text-[11px] font-bold tracking-[0.22em] text-stone-500">{{ card.label }}</p>
|
||||
<p class="mt-4 text-[34px] font-semibold tracking-[-0.06em] text-stone-900">{{ card.value }}</p>
|
||||
@@ -79,42 +97,59 @@ const emit = defineEmits(['update:range-start', 'update:range-end'])
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[minmax(0,1.3fr)_minmax(320px,0.7fr)]">
|
||||
<article class="rounded-[28px] border border-white/60 bg-white/80 p-6 shadow-paper backdrop-blur">
|
||||
<article class="rounded-[28px] border border-stone-200 bg-white/86 p-6">
|
||||
<div class="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-[11px] font-bold tracking-[0.22em] text-stone-500">WEEKLY FLOW</p>
|
||||
<p class="text-[11px] font-bold tracking-[0.22em] text-stone-500">RANGE FLOW</p>
|
||||
<h2 class="mt-2 text-2xl font-semibold tracking-[-0.05em] text-stone-900">
|
||||
선택 기간 기록 흐름
|
||||
선택 기간의 날짜별 집중 흐름
|
||||
</h2>
|
||||
<p class="mt-2 text-[12px] font-semibold leading-5 text-stone-500">
|
||||
기록이 있는 날짜만 날짜순으로 표시합니다.
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-[11px] font-semibold tracking-[0.06em] text-stone-500">
|
||||
기준 날짜: {{ selectedDateLabel }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex gap-3 overflow-x-auto pb-2">
|
||||
<div class="mt-8 flex min-h-[230px] items-stretch gap-3 overflow-x-auto border-b border-stone-200 pb-4">
|
||||
<div
|
||||
v-for="record in weeklyRecords"
|
||||
:key="record.key"
|
||||
class="flex min-w-[56px] flex-col items-center gap-3"
|
||||
class="flex min-w-[64px] flex-col items-center justify-end gap-3"
|
||||
>
|
||||
<div class="flex h-40 items-end">
|
||||
<div class="flex h-44 items-end">
|
||||
<div
|
||||
class="w-10 rounded-full bg-stone-900/90 transition-all"
|
||||
class="w-11 rounded-t-full bg-stone-900/90 transition-all"
|
||||
:style="{ height: `${record.barHeight}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-[11px] font-bold tracking-[0.12em] text-stone-500">{{ record.weekday }}</p>
|
||||
<p class="text-[11px] font-bold tracking-[0.12em] text-stone-500">{{ record.dateLabel }}</p>
|
||||
<p class="mt-1 text-[10px] font-bold tracking-[0.12em] text-stone-400">{{ record.weekday }}</p>
|
||||
<p class="mt-1 text-[11px] font-semibold text-stone-800">{{ record.focusedTime }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-if="weeklyRecords.length === 0"
|
||||
class="flex min-h-[180px] w-full items-center justify-center rounded-2xl border border-dashed border-stone-300 text-sm font-semibold text-stone-500"
|
||||
>
|
||||
선택 기간에 기록이 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="grid gap-6">
|
||||
<article class="rounded-[28px] border border-white/60 bg-white/80 p-6 shadow-paper backdrop-blur">
|
||||
<p class="text-[11px] font-bold tracking-[0.22em] text-stone-500">BEST DAY</p>
|
||||
<article class="rounded-[28px] border border-stone-200 bg-white/86 p-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-[11px] font-bold tracking-[0.22em] text-stone-500">BEST DAY</p>
|
||||
<GuideTooltip
|
||||
title="Best Day"
|
||||
description="선택 범위 안에서 FOCUSED TIME이 가장 큰 날짜를 보여줍니다."
|
||||
:dismissible="false"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="bestDay" class="mt-4">
|
||||
<h2 class="text-2xl font-semibold tracking-[-0.05em] text-stone-900">
|
||||
{{ bestDay.dateLabel }}
|
||||
@@ -128,8 +163,15 @@ const emit = defineEmits(['update:range-start', 'update:range-end'])
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article class="rounded-[28px] border border-white/60 bg-white/80 p-6 shadow-paper backdrop-blur">
|
||||
<p class="text-[11px] font-bold tracking-[0.22em] text-stone-500">RECENT RECORDS</p>
|
||||
<article class="rounded-[28px] border border-stone-200 bg-white/86 p-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-[11px] font-bold tracking-[0.22em] text-stone-500">RECENT RECORDS</p>
|
||||
<GuideTooltip
|
||||
title="Recent Records"
|
||||
description="선택 범위 안의 기록을 날짜 내림차순으로 최대 5개까지 보여줍니다."
|
||||
:dismissible="false"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-4 space-y-4">
|
||||
<div
|
||||
v-for="record in recentRecords"
|
||||
|
||||
@@ -28,29 +28,39 @@ async function request(path, { method = 'GET', token, body } = {}) {
|
||||
|
||||
export function readAuthState() {
|
||||
if (typeof window === 'undefined') {
|
||||
return { token: '', user: null }
|
||||
return { token: '', user: null, persist: false }
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(window.localStorage.getItem(AUTH_STORAGE_KEY) ?? '{"token":"","user":null}')
|
||||
const localState = JSON.parse(window.localStorage.getItem(AUTH_STORAGE_KEY) ?? 'null')
|
||||
|
||||
if (localState?.token) {
|
||||
return { ...localState, persist: true }
|
||||
}
|
||||
|
||||
const sessionState = JSON.parse(window.sessionStorage.getItem(AUTH_STORAGE_KEY) ?? 'null')
|
||||
|
||||
if (sessionState?.token) {
|
||||
return { ...sessionState, persist: false }
|
||||
}
|
||||
|
||||
return { token: '', user: null, persist: false }
|
||||
} catch (error) {
|
||||
console.warn('저장된 인증 상태를 불러오지 못했습니다.', error)
|
||||
return { token: '', user: null }
|
||||
return { token: '', user: null, persist: false }
|
||||
}
|
||||
}
|
||||
|
||||
export function persistAuthState({ token, user }) {
|
||||
export function persistAuthState({ token, user, persist = false }) {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
window.localStorage.setItem(
|
||||
AUTH_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
token,
|
||||
user,
|
||||
}),
|
||||
)
|
||||
const targetStorage = persist ? window.localStorage : window.sessionStorage
|
||||
const unusedStorage = persist ? window.sessionStorage : window.localStorage
|
||||
|
||||
unusedStorage.removeItem(AUTH_STORAGE_KEY)
|
||||
targetStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify({ token, user }))
|
||||
}
|
||||
|
||||
export function clearAuthState() {
|
||||
@@ -59,6 +69,7 @@ export function clearAuthState() {
|
||||
}
|
||||
|
||||
window.localStorage.removeItem(AUTH_STORAGE_KEY)
|
||||
window.sessionStorage.removeItem(AUTH_STORAGE_KEY)
|
||||
}
|
||||
|
||||
export async function signup({ email, password, nickname }) {
|
||||
|
||||
@@ -165,6 +165,35 @@
|
||||
.planner-sheet__meta-top > div,
|
||||
.planner-sheet__meta-bottom > div {
|
||||
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 {
|
||||
@@ -187,11 +216,14 @@
|
||||
}
|
||||
|
||||
.planner-sheet textarea {
|
||||
height: auto !important;
|
||||
min-height: 48px !important;
|
||||
overflow: visible !important;
|
||||
height: 58px !important;
|
||||
min-height: 58px !important;
|
||||
max-height: 58px !important;
|
||||
overflow: hidden !important;
|
||||
padding-top: 4px !important;
|
||||
line-height: 1.55 !important;
|
||||
white-space: pre-wrap !important;
|
||||
word-break: break-word !important;
|
||||
}
|
||||
|
||||
.print-paper--double .print-sheet-frame {
|
||||
|
||||
Reference in New Issue
Block a user