Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b18af56c3c | |||
| 3c3b0d20dd |
10
HANDOFF.md
10
HANDOFF.md
@@ -4,7 +4,7 @@
|
||||
|
||||
- 프로젝트명: 10 Minute Planner 웹 UI
|
||||
- 기술 스택: Vue 3 + Vite + TailwindCSS + JavaScript
|
||||
- 현재 기준 버전: `v0.1.37` 준비 중
|
||||
- 현재 기준 버전: `v0.1.39` 준비 중
|
||||
- Git 원격 저장소: `https://git.sori.studio/zenn/planner.sori.studio.git`
|
||||
|
||||
## 기준 디자인
|
||||
@@ -80,6 +80,8 @@
|
||||
- 현재 `1-UP`은 프레임 자체를 A4 세로에 가깝게 키우고 배율을 크게 올려 빈 여백을 줄이는 방향으로 맞추고 있다.
|
||||
- 백엔드 초안은 `Fastify + Drizzle + PostgreSQL` 조합으로 전환되었다.
|
||||
- 백엔드에는 `/api/auth/signup`, `/api/auth/login`, `/api/auth/me`가 추가되었다.
|
||||
- 백엔드에는 `/api/auth/verification/request`, `/api/auth/verification/confirm` 이메일 인증 토큰 API가 추가되었다.
|
||||
- 백엔드에는 `/api/auth/password-reset/request`, `/api/auth/password-reset/confirm` 비밀번호 재설정 토큰 API가 추가되었다.
|
||||
- 백엔드에는 `/api/planner/:entryDate` 단건 조회/저장과 `/api/planner?from=...&to=...` 범위 조회가 추가되었다.
|
||||
- 비밀번호는 Node 내장 `crypto.scrypt` 기반 해시로 저장하고, 세션 토큰은 `auth_sessions` 테이블에 해시 형태로 저장한다.
|
||||
- 초기 실행 시 `backend/src/db/init.js`에서 테이블이 없으면 자동 생성하도록 맞춰두었다.
|
||||
@@ -190,7 +192,13 @@
|
||||
- 달력 날짜 버튼은 의도상 원형이며, 현재는 `size` 고정 기준으로 다시 맞춰서 타원처럼 보이는 인상을 줄이는 방향으로 정리했다.
|
||||
- 플래너 본문, 2페이지 펼침, 인쇄 전용 `PlannerPage` 모두 `총 시간` 값은 `00시간 00분` 한글 포맷으로 통일했다.
|
||||
- 모바일 대응 이후 인쇄에서 `TIME TABLE`이 사라지던 문제를 막기 위해, print 시에는 `PlannerPage` 내부 레이아웃을 다시 가로 배치로 고정하고 타임테이블 오버플로를 해제하도록 보정했다.
|
||||
- 인쇄 레이아웃은 추가로 미세 조정해 `COMMENT` 영역이 잘리지 않도록 textarea 높이/행간을 print 전용으로 풀고, `1-UP` / `2-UP` 배율도 프레임 실측 기준으로 다시 계산했다.
|
||||
- `TODO.md`는 중복 체크 항목을 정리했고, 인증 확장을 위해 `이메일 인증 / 비밀번호 재설정 / rate limit / 메일 인프라` 작업을 별도 항목으로 추가했다.
|
||||
- Resend 무료 플랜은 도메인 1개 제약이 있어 현재 프로젝트 인증 메일에는 바로 쓰기 어렵다. 다음 단계에서는 AWS SES 또는 범용 SMTP 공급자 기준으로 메일 발송 추상화를 붙이는 쪽이 적합하다.
|
||||
- 현재 인증 메일/재설정 메일은 실제 발송 대신 개발용 `previewUrl`을 응답으로 돌려주는 단계다. 프론트 UI 연결과 실제 메일러 연결은 다음 단계에서 마무리하면 된다.
|
||||
- 미니 달력 날짜 버튼은 원형 비율이 흔들리지 않도록 고정 `width/height` 기준으로 다시 맞췄다.
|
||||
- 플래너 본문 D-DAY 텍스트는 3줄까지만 보이고, 넘치면 말줄임 처리되도록 정리했다.
|
||||
- 목표가 없는 빈 날짜에서는 `D-DAY 사용` 토글이 저장 상태와 무관하게 `OFF + 비활성`처럼 보이도록 보정했다.
|
||||
- 비로그인 랜딩 카드는 상단 고정이 아니라 화면 중앙에 오도록 정렬을 수정했다.
|
||||
- 현재 환경에서는 Docker 데몬이 꺼져 있어서 `docker compose build` 실검증은 하지 못했고, 데몬 시작 후 다시 확인이 필요하다.
|
||||
- 이미지 저장 기능은 추후 `print-only` 또는 별도 export 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다.
|
||||
|
||||
2
TODO.md
2
TODO.md
@@ -93,6 +93,7 @@
|
||||
- [ ] 비밀번호 찾기 / 재설정 토큰 흐름을 추가한다.
|
||||
- [ ] 로그인 및 인증 관련 rate limit / 잠금 정책을 추가한다.
|
||||
- [ ] 메일 발송 인프라와 발신 도메인 정책을 확정한다.
|
||||
- [ ] Resend 무료 플랜 대체 수단으로 SES 또는 일반 SMTP 연동 방식을 확정한다.
|
||||
|
||||
## 메모
|
||||
|
||||
@@ -123,3 +124,4 @@
|
||||
- 표시 기간이 다른 진행 중 목표와 겹치면 프론트와 백엔드 모두 저장을 막는다.
|
||||
- TASK LABELS는 버튼 묶음 대신 동일한 토글 패턴으로 단순화했다.
|
||||
- 구현할 때마다 완료된 항목은 체크하고, 큰 결정사항은 `HANDOFF.md`에도 함께 반영한다.
|
||||
- Resend 무료 플랜은 도메인 수 제약이 있으므로, 실제 인증 메일은 AWS SES 또는 별도 SMTP 서비스 전환을 전제로 설계하는 편이 안전하다.
|
||||
|
||||
@@ -13,6 +13,7 @@ const envSchema = z.object({
|
||||
DATABASE_URL: z.string().min(1).default('postgresql://planner:planner1234@localhost:5432/ten_minute_planner'),
|
||||
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'),
|
||||
})
|
||||
|
||||
export const env = envSchema.parse(process.env)
|
||||
|
||||
@@ -7,10 +7,14 @@ export async function ensureDatabaseSchema() {
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
nickname VARCHAR(60) NOT NULL,
|
||||
email_verified_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth_sessions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
@@ -51,5 +55,29 @@ export async function ensureDatabaseSchema() {
|
||||
|
||||
CREATE INDEX IF NOT EXISTS goals_user_id_idx
|
||||
ON goals (user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS email_verification_tokens (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash VARCHAR(255) NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS email_verification_tokens_user_id_idx
|
||||
ON email_verification_tokens (user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash VARCHAR(255) NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS password_reset_tokens_user_id_idx
|
||||
ON password_reset_tokens (user_id);
|
||||
`)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export const users = pgTable('users', {
|
||||
email: varchar('email', { length: 255 }).notNull().unique(),
|
||||
passwordHash: varchar('password_hash', { length: 255 }).notNull(),
|
||||
nickname: varchar('nickname', { length: 60 }).notNull(),
|
||||
emailVerifiedAt: timestamp('email_verified_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull(),
|
||||
})
|
||||
@@ -65,3 +66,33 @@ export const goals = pgTable(
|
||||
userIndex: index('goals_user_id_idx').on(table.userId),
|
||||
}),
|
||||
)
|
||||
|
||||
export const emailVerificationTokens = pgTable(
|
||||
'email_verification_tokens',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
tokenHash: varchar('token_hash', { length: 255 }).notNull().unique(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
usedAt: timestamp('used_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
userIndex: index('email_verification_tokens_user_id_idx').on(table.userId),
|
||||
}),
|
||||
)
|
||||
|
||||
export const passwordResetTokens = pgTable(
|
||||
'password_reset_tokens',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
tokenHash: varchar('token_hash', { length: 255 }).notNull().unique(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
usedAt: timestamp('used_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
userIndex: index('password_reset_tokens_user_id_idx').on(table.userId),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { and, eq, gt, isNull } from 'drizzle-orm'
|
||||
import { env } from '../config.js'
|
||||
import { z } from 'zod'
|
||||
import { db } from '../db/client.js'
|
||||
import { users } from '../db/schema.js'
|
||||
import { hashPassword, verifyPassword } from '../lib/password.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'
|
||||
|
||||
const signupSchema = z.object({
|
||||
@@ -26,11 +27,79 @@ const passwordSchema = z.object({
|
||||
newPassword: z.string().min(8).max(72),
|
||||
})
|
||||
|
||||
const verificationRequestSchema = z.object({
|
||||
email: z.string().trim().email().optional(),
|
||||
})
|
||||
|
||||
const verificationConfirmSchema = z.object({
|
||||
token: z.string().trim().min(20).max(255),
|
||||
})
|
||||
|
||||
const passwordResetRequestSchema = z.object({
|
||||
email: z.string().trim().email(),
|
||||
})
|
||||
|
||||
const passwordResetConfirmSchema = z.object({
|
||||
token: z.string().trim().min(20).max(255),
|
||||
newPassword: z.string().min(8).max(72),
|
||||
})
|
||||
|
||||
const TOKEN_TTL_MS = 1000 * 60 * 30
|
||||
|
||||
function buildPreviewUrl(pathname, token) {
|
||||
const url = new URL(pathname, env.APP_BASE_URL)
|
||||
url.searchParams.set('token', token)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
async function createEmailVerificationToken(userId) {
|
||||
const token = createSessionToken()
|
||||
const tokenHash = hashSessionToken(token)
|
||||
const now = new Date()
|
||||
const expiresAt = new Date(now.getTime() + TOKEN_TTL_MS)
|
||||
|
||||
await db.delete(emailVerificationTokens).where(eq(emailVerificationTokens.userId, userId))
|
||||
|
||||
await db.insert(emailVerificationTokens).values({
|
||||
userId,
|
||||
tokenHash,
|
||||
expiresAt,
|
||||
createdAt: now,
|
||||
})
|
||||
|
||||
return {
|
||||
token,
|
||||
previewUrl: buildPreviewUrl('/verify-email', token),
|
||||
}
|
||||
}
|
||||
|
||||
async function createPasswordResetToken(userId) {
|
||||
const token = createSessionToken()
|
||||
const tokenHash = hashSessionToken(token)
|
||||
const now = new Date()
|
||||
const expiresAt = new Date(now.getTime() + TOKEN_TTL_MS)
|
||||
|
||||
await db.delete(passwordResetTokens).where(eq(passwordResetTokens.userId, userId))
|
||||
|
||||
await db.insert(passwordResetTokens).values({
|
||||
userId,
|
||||
tokenHash,
|
||||
expiresAt,
|
||||
createdAt: now,
|
||||
})
|
||||
|
||||
return {
|
||||
token,
|
||||
previewUrl: buildPreviewUrl('/reset-password', token),
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeUser(user) {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
nickname: user.nickname,
|
||||
emailVerifiedAt: user.emailVerifiedAt,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
}
|
||||
@@ -71,17 +140,20 @@ export async function registerAuthRoutes(app) {
|
||||
email: normalizedEmail,
|
||||
passwordHash,
|
||||
nickname,
|
||||
emailVerifiedAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning()
|
||||
|
||||
const { token } = await createSession(user.id)
|
||||
const verification = await createEmailVerificationToken(user.id)
|
||||
|
||||
return reply.code(201).send({
|
||||
message: '회원가입이 완료되었습니다.',
|
||||
token,
|
||||
user: sanitizeUser(user),
|
||||
verificationPreviewUrl: verification.previewUrl,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -228,4 +300,188 @@ export async function registerAuthRoutes(app) {
|
||||
message: '비밀번호가 변경되었습니다.',
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/auth/verification/request', async (request, reply) => {
|
||||
const authenticatedUser = await findAuthenticatedUser(request)
|
||||
const payload = verificationRequestSchema.safeParse(request.body ?? {})
|
||||
|
||||
if (!payload.success) {
|
||||
return reply.code(400).send({
|
||||
message: '이메일 입력값이 올바르지 않습니다.',
|
||||
issues: payload.error.flatten(),
|
||||
})
|
||||
}
|
||||
|
||||
const normalizedEmail = (payload.data.email || authenticatedUser?.email || '').toLowerCase()
|
||||
|
||||
if (!normalizedEmail) {
|
||||
return reply.code(400).send({
|
||||
message: '인증 메일을 받을 이메일이 필요합니다.',
|
||||
})
|
||||
}
|
||||
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, normalizedEmail))
|
||||
.limit(1)
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
message: '입력한 이메일로 인증 안내를 보낼 준비가 되면 처리됩니다.',
|
||||
}
|
||||
}
|
||||
|
||||
if (user.emailVerifiedAt) {
|
||||
return {
|
||||
message: '이미 이메일 인증이 완료된 계정입니다.',
|
||||
}
|
||||
}
|
||||
|
||||
const verification = await createEmailVerificationToken(user.id)
|
||||
|
||||
return {
|
||||
message: '이메일 인증 링크를 준비했습니다.',
|
||||
verificationPreviewUrl: verification.previewUrl,
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/auth/verification/confirm', async (request, reply) => {
|
||||
const payload = verificationConfirmSchema.safeParse(request.body)
|
||||
|
||||
if (!payload.success) {
|
||||
return reply.code(400).send({
|
||||
message: '인증 토큰이 올바르지 않습니다.',
|
||||
issues: payload.error.flatten(),
|
||||
})
|
||||
}
|
||||
|
||||
const tokenHash = hashSessionToken(payload.data.token)
|
||||
|
||||
const [verification] = await db
|
||||
.select()
|
||||
.from(emailVerificationTokens)
|
||||
.where(
|
||||
and(
|
||||
eq(emailVerificationTokens.tokenHash, tokenHash),
|
||||
isNull(emailVerificationTokens.usedAt),
|
||||
gt(emailVerificationTokens.expiresAt, new Date()),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!verification) {
|
||||
return reply.code(400).send({
|
||||
message: '이미 사용했거나 만료된 인증 링크입니다.',
|
||||
})
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
emailVerifiedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(users.id, verification.userId))
|
||||
|
||||
await db
|
||||
.update(emailVerificationTokens)
|
||||
.set({
|
||||
usedAt: now,
|
||||
})
|
||||
.where(eq(emailVerificationTokens.id, verification.id))
|
||||
|
||||
return {
|
||||
message: '이메일 인증이 완료되었습니다.',
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/auth/password-reset/request', async (request, reply) => {
|
||||
const payload = passwordResetRequestSchema.safeParse(request.body)
|
||||
|
||||
if (!payload.success) {
|
||||
return reply.code(400).send({
|
||||
message: '이메일 입력값이 올바르지 않습니다.',
|
||||
issues: payload.error.flatten(),
|
||||
})
|
||||
}
|
||||
|
||||
const normalizedEmail = payload.data.email.toLowerCase()
|
||||
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, normalizedEmail))
|
||||
.limit(1)
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
message: '입력한 이메일로 재설정 안내를 보낼 준비가 되면 처리됩니다.',
|
||||
}
|
||||
}
|
||||
|
||||
const reset = await createPasswordResetToken(user.id)
|
||||
|
||||
return {
|
||||
message: '비밀번호 재설정 링크를 준비했습니다.',
|
||||
resetPreviewUrl: reset.previewUrl,
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/auth/password-reset/confirm', async (request, reply) => {
|
||||
const payload = passwordResetConfirmSchema.safeParse(request.body)
|
||||
|
||||
if (!payload.success) {
|
||||
return reply.code(400).send({
|
||||
message: '비밀번호 재설정 입력값이 올바르지 않습니다.',
|
||||
issues: payload.error.flatten(),
|
||||
})
|
||||
}
|
||||
|
||||
const tokenHash = hashSessionToken(payload.data.token)
|
||||
|
||||
const [resetToken] = await db
|
||||
.select()
|
||||
.from(passwordResetTokens)
|
||||
.where(
|
||||
and(
|
||||
eq(passwordResetTokens.tokenHash, tokenHash),
|
||||
isNull(passwordResetTokens.usedAt),
|
||||
gt(passwordResetTokens.expiresAt, new Date()),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!resetToken) {
|
||||
return reply.code(400).send({
|
||||
message: '이미 사용했거나 만료된 재설정 링크입니다.',
|
||||
})
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const passwordHash = await hashPassword(payload.data.newPassword)
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
passwordHash,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(users.id, resetToken.userId))
|
||||
|
||||
await db
|
||||
.update(passwordResetTokens)
|
||||
.set({
|
||||
usedAt: now,
|
||||
})
|
||||
.where(eq(passwordResetTokens.id, resetToken.id))
|
||||
|
||||
await db.delete(authSessions).where(eq(authSessions.userId, resetToken.userId))
|
||||
|
||||
return {
|
||||
message: '비밀번호가 재설정되었습니다. 다시 로그인해 주세요.',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ten-minute-planner",
|
||||
"version": "0.1.37",
|
||||
"version": "0.1.39",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ten-minute-planner",
|
||||
"version": "0.1.37",
|
||||
"version": "0.1.39",
|
||||
"dependencies": {
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ten-minute-planner",
|
||||
"private": true,
|
||||
"version": "0.1.37",
|
||||
"version": "0.1.39",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
11
src/App.vue
11
src/App.vue
@@ -212,7 +212,7 @@ function startOfDay(date) {
|
||||
function buildFallbackRecord(date) {
|
||||
return {
|
||||
comment: '',
|
||||
goalEnabled: true,
|
||||
goalEnabled: false,
|
||||
selectedGoalId: null,
|
||||
tasks: Array.from({ length: 15 }, (_, index) => ({
|
||||
label: '',
|
||||
@@ -363,6 +363,7 @@ const activePlannerGoals = computed(() =>
|
||||
}),
|
||||
)
|
||||
const plannerGoal = computed(() => activePlannerGoals.value[0] ?? null)
|
||||
const plannerGoalToggleOn = computed(() => Boolean(plannerGoal.value) && planner.value.goalEnabled)
|
||||
const plannerDday = computed(() => {
|
||||
if (!planner.value.goalEnabled || !plannerGoal.value) {
|
||||
return ''
|
||||
@@ -1930,13 +1931,13 @@ onBeforeUnmount(() => {
|
||||
<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="planner.goalEnabled ? 'bg-stone-900' : 'bg-stone-300'"
|
||||
:class="plannerGoalToggleOn ? 'bg-stone-900' : 'bg-stone-300'"
|
||||
:disabled="!hasActiveGoalForSelectedDate"
|
||||
@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="planner.goalEnabled ? 'translate-x-8' : 'translate-x-0'"
|
||||
:class="plannerGoalToggleOn ? 'translate-x-8' : 'translate-x-0'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
@@ -2091,13 +2092,13 @@ onBeforeUnmount(() => {
|
||||
<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="planner.goalEnabled ? 'bg-stone-900' : 'bg-stone-300'"
|
||||
:class="plannerGoalToggleOn ? 'bg-stone-900' : 'bg-stone-300'"
|
||||
:disabled="!hasActiveGoalForSelectedDate"
|
||||
@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="planner.goalEnabled ? 'translate-x-8' : 'translate-x-0'"
|
||||
:class="plannerGoalToggleOn ? 'translate-x-8' : 'translate-x-0'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -132,7 +132,7 @@ function selectYear(year) {
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="relative flex size-8 items-center justify-center rounded-full border text-[10px] font-semibold transition sm:size-10 sm:text-[11px]"
|
||||
class="relative flex h-10 w-10 shrink-0 items-center justify-center rounded-full border text-[10px] font-semibold transition sm:h-[44px] sm:w-[44px] sm:text-[11px]"
|
||||
:class="[
|
||||
day.key === selectedKey
|
||||
? 'border-ink bg-ink text-white'
|
||||
|
||||
@@ -132,7 +132,19 @@ onBeforeUnmount(() => {
|
||||
</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>
|
||||
<p class="pt-5 text-[11px] tracking-[0.2em] text-ink sm:pt-6 sm:text-sm">{{ dday }}</p>
|
||||
<p
|
||||
class="pt-5 text-[11px] tracking-[0.14em] text-ink sm:pt-6 sm:text-sm"
|
||||
style="
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
word-break: break-word;
|
||||
"
|
||||
>
|
||||
{{ dday }}
|
||||
</p>
|
||||
</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">
|
||||
@@ -147,7 +159,7 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
</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">FOCUSED TIME</span>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
}
|
||||
|
||||
@media print {
|
||||
@page {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background: #ffffff !important;
|
||||
@@ -125,7 +129,7 @@
|
||||
width: 762px !important;
|
||||
max-width: none !important;
|
||||
transform-origin: top left;
|
||||
transform: scale(0.978);
|
||||
transform: scale(0.982);
|
||||
box-shadow: none !important;
|
||||
background: #ffffff !important;
|
||||
}
|
||||
@@ -134,7 +138,7 @@
|
||||
width: 762px !important;
|
||||
max-width: none !important;
|
||||
transform-origin: top left;
|
||||
transform: scale(0.684);
|
||||
transform: scale(0.6894);
|
||||
box-shadow: none !important;
|
||||
background: #ffffff !important;
|
||||
}
|
||||
@@ -152,6 +156,17 @@
|
||||
gap: 16px !important;
|
||||
}
|
||||
|
||||
.planner-sheet__meta {
|
||||
gap: 14px !important;
|
||||
padding-top: 10px !important;
|
||||
padding-bottom: 10px !important;
|
||||
}
|
||||
|
||||
.planner-sheet__meta-top > div,
|
||||
.planner-sheet__meta-bottom > div {
|
||||
min-height: 78px !important;
|
||||
}
|
||||
|
||||
.planner-sheet__lists {
|
||||
width: 394px !important;
|
||||
flex: 1 1 auto !important;
|
||||
@@ -170,6 +185,19 @@
|
||||
.planner-sheet__timetable-grid {
|
||||
min-width: 210px !important;
|
||||
}
|
||||
|
||||
.planner-sheet textarea {
|
||||
height: auto !important;
|
||||
min-height: 48px !important;
|
||||
overflow: visible !important;
|
||||
padding-top: 4px !important;
|
||||
line-height: 1.55 !important;
|
||||
}
|
||||
|
||||
.print-paper--double .print-sheet-frame {
|
||||
align-items: flex-start !important;
|
||||
justify-content: flex-start !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen {
|
||||
|
||||
Reference in New Issue
Block a user