Compare commits

...
21 Commits
Author SHA1 Message Date
shinalok 74bfe34d3f Merge branch 'ios-only-plain-textarea-fallback' into main
CI/CD / test (push) Successful in 46s
CI/CD / deploy (push) Successful in 1m38s
2026-08-27 08:16:10 +09:00
shinalokandClaude Sonnet 5 6000de166f keep EasyMDE on desktop/Android, fall back to plain textarea only on iOS
Dropping EasyMDE entirely (previous commit) fixed the Korean IME jamo split
but also removed the live markdown syntax highlighting everywhere, even on
platforms that never had the bug. Only iOS WebKit breaks CJK IME composition
under CodeMirror, so journal-editor.js now detects iOS (including the
desktop-UA-masquerading iPad, via MacIntel + multi-touch) and only skips
EasyMDE there, falling back to a native <textarea>. Everywhere else keeps
the full EasyMDE/CodeMirror editing experience as before.

window.JournalEditor now dispatches to the right engine per textarea
(checking t._easymde) so the toolbar buttons and the category-template
autofill in journal.html work unchanged regardless of which engine backs a
given textarea. Restores the easymde vendor files, base.html includes, and
service worker precache entries removed in the previous commit (cache
bumped to v8).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 08:16:06 +09:00
shinalok f524157326 Merge branch 'remove-easymde-fix-ime' into main 2026-08-26 20:23:13 +09:00
shinalokandClaude Sonnet 5 ac98cf4f99 replace EasyMDE journal editor with plain textarea to fix iOS Korean IME jamo split
Forcing inputStyle to contenteditable (previous commit) didn't fix it on
iPhone, confirming this is CodeMirror 5's own long-standing weakness with
CJK IME composition on iOS WebKit, not just the iPad desktop-UA detection
issue. There's no reliable fix short of dropping CodeMirror for the journal
content field, so EasyMDE is removed entirely and the textarea goes back to
a native, uncontrolled <textarea> — nothing intercepts/re-renders it during
composition, so iOS's IME just works.

The markdown toolbar buttons (bold/italic/heading/quote/lists/code/link)
now manipulate the textarea's selection directly via a small JournalEditor
JS API instead of calling EasyMDE/CodeMirror commands, and image paste
tracks its upload placeholder by a unique text marker instead of a
CodeMirror bookmark. Also drops the vendored easymde.min.js/css (no longer
referenced) and bumps the service worker CACHE_NAME so stale cached copies
of the old vendor files get evicted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 20:23:09 +09:00
shinalok 291813820b Merge branch 'fix-ios-ime-jamo-split' into main
CI/CD / test (push) Successful in 48s
CI/CD / deploy (push) Successful in 1m33s
2026-08-26 12:43:58 +09:00
shinalokandClaude Sonnet 5 779bbb58aa force EasyMDE contenteditable input mode to fix Korean IME jamo splitting on iPad
iPadOS 13+ sends a desktop Safari UA by default, which fools CodeMirror's mobile detection into using the more fragile textarea input mode. That, combined with CodeMirror repainting the line mid-composition, breaks Korean IME composition and leaves jamo unmerged. inputStyle can't be changed after the editor is created, so it must be set in the EasyMDE constructor options.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 12:43:45 +09:00
shinalok 46c412f8f3 Merge pull request 'enlarge journal calendar cells and pair day/title on one line' (#1) from journal-calendar-ui-tweaks into main
CI/CD / test (push) Successful in 30s
CI/CD / deploy (push) Successful in 2m15s
Reviewed-on: #1
2026-08-19 17:18:22 +09:00
shinalokandClaude Sonnet 5 8a49757882 enlarge journal calendar cells and pair day/title on one line
CI/CD / test (pull_request) Successful in 51s
CI/CD / deploy (pull_request) Skipped
- calendar-cell-journal: bump min-height 44px -> 76px, scale up date/dot/title font sizes
- journal calendar titles were centered; left-align them
- rework JournalCalendarDay: replace separate category_colors/titles lists
  (mismatched lengths, no way to pair a dot with its title) with a single
  entries list of (color, title) pairs so the dot and its title render on
  the same non-wrapping row per entry

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 17:13:38 +09:00
shinalokandClaude Opus 5 b173911269 make the app name the largest text on the landing page
CI/CD / test (push) Successful in 32s
CI/CD / deploy (push) Successful in 1m29s
The branding review no longer flags the missing purpose description, but
still reports that the app name does not match the homepage — even though
the console value and the h1 text are identical strings.

The likely reason is visual hierarchy: the app name was a 15px label while
the tagline underneath it was 32px, so the tagline read as the site's name.
The name and the tagline now swap sizes, and the logo moves above the
heading instead of sitting inline next to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 10:34:15 +09:00
shinalokandClaude Opus 5 0e8688c25b make app name and purpose machine-readable on the landing page
CI/CD / test (push) Successful in 45s
CI/CD / deploy (push) Successful in 1m29s
Google OAuth branding review rejected the app twice with "no description of
the app purpose on the homepage" and "app name does not match the homepage",
even though both were present in Korean. Two likely causes, both addressed:

- The h1 wrapped an alt="" logo image before the text, so the app name could
  not be extracted from it. The image now sits outside the h1, leaving the
  heading as plain text.
- The page was Korean-only. The app name is now "해빗랩 (HabitLab)" (matching
  the console exactly), and the title, meta description and a new About
  section carry an English description of the app purpose and its use of
  Google account data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:29:42 +09:00
shinalok ad5a1c834c add terms page, improve landing content for branding review, and handle HTTP HEAD requests
CI/CD / test (push) Successful in 40s
CI/CD / deploy (push) Successful in 1m44s
2026-08-08 21:30:54 +09:00
shinalok be8a4e6908 add terms page, improve landing content for branding review, and handle HTTP HEAD requests
CI/CD / test (push) Successful in 46s
CI/CD / deploy (push) Successful in 1m44s
2026-08-08 20:40:47 +09:00
shinalok e752367f79 remove SSH debug connection test from CI/CD workflow for cleaner output and simplify rsync verbosity
CI/CD / test (push) Successful in 42s
CI/CD / deploy (push) Successful in 43s
2026-08-08 07:38:50 +09:00
shinalok 689d32e18b update CI/CD workflow: add unused Docker image cleanup step
CI/CD / test (push) Successful in 43s
CI/CD / deploy (push) Successful in 49s
2026-08-08 07:35:14 +09:00
shinalok bf0225d53b add SSH debug connection test to CI/CD workflow and enable batch mode for rsync
CI/CD / test (push) Successful in 40s
CI/CD / deploy (push) Successful in 1m57s
2026-08-08 07:25:51 +09:00
shinalok 5b1a9ac683 add SSH debug connection test to CI/CD workflow and enable batch mode for rsync
CI/CD / test (push) Successful in 43s
CI/CD / deploy (push) Failing after 37s
2026-08-08 07:13:27 +09:00
shinalok 97c79b8b04 add SSH debug connection test to CI/CD workflow and enable batch mode for rsync
CI/CD / test (push) Successful in 53s
CI/CD / deploy (push) Failing after 43s
2026-08-08 07:07:38 +09:00
shinalok a0a7a70c14 update CI/CD workflow: decode base64 SSH key and validate key integrity
CI/CD / test (push) Successful in 40s
CI/CD / deploy (push) Failing after 35s
2026-08-07 22:32:57 +09:00
shinalok c0d14967d0 update CI/CD workflow: install development dependencies for testing with pip
CI/CD / test (push) Successful in 51s
CI/CD / deploy (push) Failing after 1m0s
2026-08-07 19:31:07 +09:00
shinalok c09aadaeb5 update CI/CD workflow: replace containerized testing and deployment with system-installed dependencies
CI/CD / test (push) Failing after 3m54s
CI/CD / deploy (push) Has been skipped
2026-08-07 19:23:47 +09:00
shinalok 61142ed55e add CI/CD workflow for testing and deployment steps on main branch push
CI/CD / test (push) Failing after 8s
CI/CD / deploy (push) Has been skipped
2026-08-07 19:04:30 +09:00
14 changed files with 596 additions and 111 deletions
+72
View File
@@ -0,0 +1,72 @@
name: CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: 저장소 체크아웃
uses: actions/checkout@v4
- name: Python 3.13 설치
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: 테스트용 .env 준비
run: |
cat > .env <<'EOF'
SECRET_KEY=ci-dummy-secret-key
DATABASE_URL=sqlite:///./ci.db
EOF
- name: 의존성 설치
run: pip install -e ".[dev]"
- name: pytest 실행
run: pytest
deploy:
needs: test
if: gitea.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: 저장소 체크아웃
uses: actions/checkout@v4
- name: ssh/rsync 설치
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends openssh-client rsync
- name: SSH 키 준비
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_SSH_KEY }}" | base64 -d > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -p ${{ vars.DEPLOY_SSH_PORT }} ${{ vars.DEPLOY_SSH_HOST }} >> ~/.ssh/known_hosts
# 키가 깨진 채로 저장됐다면 여기서 바로 에러가 나서 원인을 알 수 있음
ssh-keygen -y -f ~/.ssh/id_ed25519 > /dev/null
- name: 소스 동기화
run: |
rsync -avz \
--exclude='.env' --exclude='media' --exclude='__pycache__' \
-e "ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes -o IdentitiesOnly=yes -p ${{ vars.DEPLOY_SSH_PORT }}" \
pyproject.toml alembic.ini Dockerfile docker-compose.yml app migrations scripts \
${{ vars.DEPLOY_SSH_USER }}@${{ vars.DEPLOY_SSH_HOST }}:${{ vars.DEPLOY_REMOTE_PATH }}/
- name: 컨테이너 재빌드 & 재시작
run: |
ssh -p ${{ vars.DEPLOY_SSH_PORT }} ${{ vars.DEPLOY_SSH_USER }}@${{ vars.DEPLOY_SSH_HOST }} \
"export PATH=\$PATH:/usr/local/bin && cd ${{ vars.DEPLOY_REMOTE_PATH }} && docker compose build && docker compose up -d"
- name: 안 쓰는 이미지 정리
run: |
ssh -p ${{ vars.DEPLOY_SSH_PORT }} ${{ vars.DEPLOY_SSH_USER }}@${{ vars.DEPLOY_SSH_HOST }} \
"export PATH=\$PATH:/usr/local/bin && docker image prune -f"
+21
View File
@@ -42,6 +42,27 @@ async def redirect_http_to_https(request: Request, call_next):
return await call_next(request) return await call_next(request)
@app.middleware("http")
async def support_head_requests(request: Request, call_next):
# 이 Starlette 버전은 GET 라우트에 HEAD를 자동으로 열어주지 않아 크롤러의 HEAD 요청이
# 전부 405로 막힌다(구글 OAuth 브랜딩 인증 크롤러가 홈페이지를 HEAD로 먼저 확인하면서
# "콘텐츠 없음"으로 오판하는 원인이 됐음). GET과 동일하게 처리한 뒤 본문만 비워서 응답한다.
if request.method != "HEAD":
return await call_next(request)
request.scope["method"] = "GET"
response = await call_next(request)
async def _empty_body():
return
yield b"" # pragma: no cover - 제너레이터로 만들기 위한 도달 불가 코드
response.body_iterator = _empty_body()
# uvicorn은 실제 전송 바이트와 Content-Length가 다르면 예외를 던지므로 0으로 맞춘다.
response.headers["content-length"] = "0"
return response
app.mount("/static", StaticFiles(directory="app/static"), name="static") app.mount("/static", StaticFiles(directory="app/static"), name="static")
app.include_router(auth.router) app.include_router(auth.router)
+8
View File
@@ -68,6 +68,14 @@ def privacy_page(request: Request, db: Session = Depends(get_db)):
) )
@router.get("/terms")
def terms_page(request: Request, db: Session = Depends(get_db)):
user = get_current_user_optional(request, db)
return templates.TemplateResponse(
request, "terms.html", {"logged_in": user is not None, "current_user": user}
)
@router.get("/account") @router.get("/account")
def account_page(request: Request, db: Session = Depends(get_db)): def account_page(request: Request, db: Session = Depends(get_db)):
current = _current_user_or_redirect(request, db) current = _current_user_or_redirect(request, db)
+6 -1
View File
@@ -116,10 +116,15 @@ class JournalAttachmentOut(BaseModel):
has_thumbnail: bool has_thumbnail: bool
class JournalCalendarEntry(BaseModel):
color: str # 그 엔트리가 속한 카테고리 색상 (점 표시용)
title: str # 제목(없으면 내용 일부)
class JournalCalendarDay(BaseModel): class JournalCalendarDay(BaseModel):
entry_date: date entry_date: date
total_count: int total_count: int
category_colors: list[str] # 그 날 엔트리가 있는 카테고리들의 색상(점 표시용, 중복 제거) entries: list[JournalCalendarEntry] # 엔트리별 (색상, 제목) 쌍 — 점과 제목이 한 줄에 붙어 나오도록 1:1로 매칭
class JournalDayDetailItem(BaseModel): class JournalDayDetailItem(BaseModel):
+17 -8
View File
@@ -23,6 +23,7 @@ from app.models.journal import (
from app.schemas.journal import ( from app.schemas.journal import (
JournalAttachmentOut, JournalAttachmentOut,
JournalCalendarDay, JournalCalendarDay,
JournalCalendarEntry,
JournalCategoryCreate, JournalCategoryCreate,
JournalDayDetailItem, JournalDayDetailItem,
JournalEntryCreate, JournalEntryCreate,
@@ -363,25 +364,33 @@ def get_monthly_journal_summary(
last_day = date(year, month, days_in_month) last_day = date(year, month, days_in_month)
stmt = ( stmt = (
select(JournalEntry.entry_date, JournalCategory.color) select(JournalEntry.entry_date, JournalCategory.color, JournalEntry.title, JournalEntry.content)
.join(JournalCategory, JournalEntry.category_id == JournalCategory.id) .join(JournalCategory, JournalEntry.category_id == JournalCategory.id)
.where(JournalEntry.user_id == user_id, JournalEntry.entry_date.between(first_day, last_day)) .where(JournalEntry.user_id == user_id, JournalEntry.entry_date.between(first_day, last_day))
.order_by(JournalEntry.entry_date, JournalEntry.created_at)
) )
if category_id is not None: if category_id is not None:
stmt = stmt.where(JournalEntry.category_id == category_id) stmt = stmt.where(JournalEntry.category_id == category_id)
rows = db.execute(stmt).all() rows = db.execute(stmt).all()
counts: dict[date, int] = {} counts: dict[date, int] = {}
colors_by_date: dict[date, list[str]] = {} entries_by_date: dict[date, list[JournalCalendarEntry]] = {}
for entry_date, color in rows: for entry_date, color, title, content in rows:
counts[entry_date] = counts.get(entry_date, 0) + 1 counts[entry_date] = counts.get(entry_date, 0) + 1
colors = colors_by_date.setdefault(entry_date, []) entries = entries_by_date.setdefault(entry_date, [])
color = color or "var(--color-accent)" entries.append(
if color not in colors: JournalCalendarEntry(
colors.append(color) color=color or "var(--color-accent)",
title=title or (content[:12] + ("" if len(content) > 12 else "")),
)
)
return { return {
d: JournalCalendarDay(entry_date=d, total_count=counts[d], category_colors=colors_by_date[d]) d: JournalCalendarDay(
entry_date=d,
total_count=counts[d],
entries=entries_by_date[d],
)
for d in counts for d in counts
} }
+101 -21
View File
@@ -121,6 +121,11 @@ p {
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.app-footer a + a::before {
content: "·";
margin: 0 6px;
}
/* iOS 홈 화면 추가 안내 배너 */ /* iOS 홈 화면 추가 안내 배너 */
.ios-install-banner { .ios-install-banner {
display: none; display: none;
@@ -867,15 +872,48 @@ label {
} }
/* 저널링 */ /* 저널링 */
.journal-day-dots { .calendar-cell-journal {
aspect-ratio: auto;
min-height: 76px;
padding: 6px 5px;
overflow: hidden;
}
.calendar-cell-journal .calendar-date {
font-size: 13px;
}
.journal-day-entries {
display: flex; display: flex;
gap: 2px; flex-direction: column;
align-items: flex-start;
width: 100%;
}
.journal-day-entry {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
min-width: 0;
} }
.journal-day-dot { .journal-day-dot {
width: 5px; width: 6px;
height: 5px; height: 6px;
border-radius: 50%; border-radius: 50%;
flex-shrink: 0;
}
.journal-day-title {
min-width: 0;
font-size: 10px;
line-height: 1.3;
color: var(--color-text-muted);
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.journal-prompt-card, .journal-prompt-card,
@@ -941,7 +979,10 @@ label {
background: var(--color-bg); background: var(--color-bg);
} }
/* EasyMDE(CodeMirror) 컨테이너를 이 앱의 입력 필드 톤에 맞춘다 */ /* EasyMDE(CodeMirror) 컨테이너를 이 앱의 입력 필드 톤에 맞춘다. iOS에서는
app/static/js/journal-editor.js가 EasyMDE 대신 순수 <textarea>로 폴백하므로(한글 IME
자소분리 회피) 이 규칙은 그 경우 그냥 매칭되지 않는다 — .markdown-editor 자체는 위 공통
textarea 규칙을 그대로 물려받아 별도 스타일 없이도 정상적으로 보인다. */
.markdown-editor + .EasyMDEContainer .CodeMirror { .markdown-editor + .EasyMDEContainer .CodeMirror {
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-control); border-radius: var(--radius-control);
@@ -1131,28 +1172,30 @@ label {
text-align: center; text-align: center;
} }
.landing-logo {
width: 56px;
height: 56px;
border-radius: 14px;
}
/* 앱 이름 h1 — 화면에서 가장 큰 글자여야 한다(위 home.html 주석 참고). */
.landing-brand { .landing-brand {
display: flex; margin: 0;
align-items: center;
gap: 8px;
font-size: 15px;
font-weight: 700;
color: var(--color-accent);
letter-spacing: -0.01em;
}
.landing-brand img {
width: 24px;
height: 24px;
border-radius: 6px;
}
.landing-hero h1 {
font-size: 32px; font-size: 32px;
font-weight: 700; font-weight: 700;
letter-spacing: -0.02em; letter-spacing: -0.02em;
text-wrap: balance; text-wrap: balance;
color: var(--color-accent);
}
/* 태그라인은 앱 이름 아래 부제 */
.landing-hero-title {
font-size: 18px;
font-weight: 600;
letter-spacing: -0.01em;
text-wrap: balance;
margin: 0; margin: 0;
color: var(--color-text);
} }
.landing-tagline { .landing-tagline {
@@ -1263,3 +1306,40 @@ label {
font-size: 14.5px; font-size: 14.5px;
margin-bottom: 2px; margin-bottom: 2px;
} }
/* 기능 설명 / 계정 안내 — 구글 브랜딩 심사가 홈페이지에서 확인하는 "앱의 목적" 설명 영역. */
.landing-section h2 {
font-size: 17px;
font-weight: 700;
margin: 0 0 var(--space-2);
}
.landing-list {
margin: 0;
padding-left: 1.1em;
display: flex;
flex-direction: column;
gap: 10px;
font-size: 14.5px;
line-height: 1.65;
color: var(--color-text);
}
.landing-list strong {
color: var(--color-accent);
}
.landing-note {
margin: 0 0 var(--space-2);
font-size: 13.5px;
line-height: 1.7;
color: var(--color-text-muted);
}
.landing-note:last-child {
margin-bottom: 0;
}
.landing-note strong {
color: var(--color-text);
}
+248 -59
View File
@@ -1,92 +1,230 @@
(function () { (function () {
// 내장 툴바(toolbar: false)는 안 쓴다 — EasyMDE 기본 툴바 아이콘은 Font Awesome CDN을 전제로 // 저널 본문 입력창(.markdown-editor)의 편집 엔진을 기기별로 나눈다.
// 하는데, 이 앱은 CDN을 안 쓰는 게 원칙이라 대신 journal.html/journal_day_detail.html에서 //
// 직접 만든 텍스트 버튼이 아래 EDITOR_ACTIONS로 EasyMDE 인스턴스 메서드를 호출한다. // EasyMDE(CodeMirror 5 기반)는 타이핑 중 문법에 색을 입혀 보여주는 진짜 마크다운 에디터
function initEditors(root) { // 경험을 주지만, iOS Safari에서 한글처럼 여러 keystroke를 조합해 한 글자를 완성하는 IME
if (typeof EasyMDE === "undefined") return; // 입력 중에 CodeMirror가 화면을 다시 그리면서 조합 버퍼를 끊어버려 자소가 분리된 채로
var scope = root instanceof Element ? root : document; // 남는 문제(자소분리)가 있다. inputStyle을 contenteditable로 강제해도 아이폰에서까지
var textareas = scope.querySelectorAll("textarea.markdown-editor:not([data-easymde-initialized])"); // 재현되는 걸 확인했다 — CodeMirror5 자체의 CJK IME 한계로 보고, iOS에서만 순수
// <textarea>로 폴백한다(브라우저 네이티브 입력 처리를 그대로 쓰면 IME가 깨질 이유가
// 없다). PC/안드로이드 등 iOS가 아닌 환경은 지금까지처럼 EasyMDE를 그대로 쓴다.
//
// 굵게/기울임 같은 툴바 버튼과 카테고리 템플릿 자동 채우기는 window.JournalEditor를
// 통해 호출되는데, 이 객체가 각 textarea에 EasyMDE가 붙어있는지(t._easymde) 보고
// EasyMDE 명령 또는 아래의 직접 선택 영역 조작 중 알맞은 쪽으로 위임한다 — 호출하는
// 템플릿 쪽(journal_editor_toolbar.html, journal.html)은 어느 엔진이 쓰이는지 몰라도 된다.
textareas.forEach(function (textarea) { function isIOS() {
textarea.setAttribute("data-easymde-initialized", "true"); var ua = navigator.userAgent || "";
if (/iPad|iPhone|iPod/.test(ua)) return true;
// iPadOS 13+는 기본 설정에서 데스크톱 Safari인 척하는 UA를 보낸다("Macintosh"로 위장,
// "Request Mobile Website"를 켜지 않는 한). 이런 위장 아이패드를 잡아내는 표준적인
// 방법은 "Mac인데 멀티터치가 된다"는 조합을 보는 것이다(실제 맥은 터치스크린이 없음).
return navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1;
}
var easymde = new EasyMDE({ function fireInput(textarea) {
element: textarea, textarea.dispatchEvent(new Event("input", { bubbles: true }));
toolbar: false, }
spellChecker: false,
autoDownloadFontAwesome: false,
status: false,
placeholder: textarea.getAttribute("placeholder") || "",
minHeight: (textarea.getAttribute("rows") || 6) * 24 + "px",
// 글머리 목록 버튼/Enter 자동 이어쓰기가 기본 "*" 대신 "-"를 쓰게 한다.
// 서버 렌더링(app/markdown_utils.py)은 -/*/+ 전부 동일하게 처리하니 렌더링과는 무관하고
// 순수하게 에디터가 새로 만들어주는 글머리 기호에 대한 취향 설정이다.
unorderedListStyle: "-",
});
// CodeMirror는 원본 textarea와 실시간으로 값이 동기화되지 않는다(.save()를 명시적으로 // ---- 순수 textarea 모드에서 툴바가 쓰는 선택 영역 조작 ----
// 불러야 함) — 매 변경마다 저장하고, htmx 미리보기(hx-trigger="input ...")와
// Alpine x-model이 둘 다 반응하도록 input 이벤트를 합성해서 던진다.
easymde.codemirror.on("change", function () {
easymde.codemirror.save();
textarea.dispatchEvent(new Event("input", { bubbles: true }));
});
easymde.codemirror.on("paste", function (cm, event) { function insertAtCursor(textarea, text) {
handleImagePaste(cm, event); var start = textarea.selectionStart;
}); var end = textarea.selectionEnd;
var value = textarea.value;
textarea.value = value.slice(0, start) + text + value.slice(end);
var pos = start + text.length;
textarea.selectionStart = textarea.selectionEnd = pos;
fireInput(textarea);
}
textarea._easymde = easymde; function replaceMarker(textarea, marker, replacement) {
var idx = textarea.value.indexOf(marker);
if (idx === -1) return;
var before = textarea.value.slice(0, idx);
var after = textarea.value.slice(idx + marker.length);
textarea.value = before + replacement + after;
var pos = before.length + replacement.length;
textarea.selectionStart = textarea.selectionEnd = pos;
fireInput(textarea);
}
function wrapSelection(textarea, prefix, suffix) {
if (suffix === undefined) suffix = prefix;
var start = textarea.selectionStart;
var end = textarea.selectionEnd;
var value = textarea.value;
var selected = value.slice(start, end);
textarea.value = value.slice(0, start) + prefix + selected + suffix + value.slice(end);
textarea.selectionStart = start + prefix.length;
textarea.selectionEnd = start + prefix.length + selected.length;
textarea.focus();
fireInput(textarea);
}
function currentLineRange(textarea) {
var start = textarea.selectionStart;
var end = textarea.selectionEnd;
var value = textarea.value;
var lineStart = value.lastIndexOf("\n", start - 1) + 1;
var lineEnd = value.indexOf("\n", end);
if (lineEnd === -1) lineEnd = value.length;
return { lineStart: lineStart, lineEnd: lineEnd, block: value.slice(lineStart, lineEnd) };
}
function replaceBlock(textarea, range, newBlock) {
var value = textarea.value;
textarea.value = value.slice(0, range.lineStart) + newBlock + value.slice(range.lineEnd);
textarea.selectionStart = range.lineStart;
textarea.selectionEnd = range.lineStart + newBlock.length;
textarea.focus();
fireInput(textarea);
}
function toggleLinePrefix(textarea, prefix) {
var range = currentLineRange(textarea);
var lines = range.block.split("\n");
var allPrefixed = lines.every(function (line) { return line.indexOf(prefix) === 0; });
var newLines = lines.map(function (line) {
if (allPrefixed) return line.slice(prefix.length);
return line.indexOf(prefix) === 0 ? line : prefix + line;
});
replaceBlock(textarea, range, newLines.join("\n"));
}
function toggleOrderedListPlain(textarea) {
var range = currentLineRange(textarea);
var lines = range.block.split("\n");
var re = /^\d+\.\s/;
var allNumbered = lines.every(function (line) { return re.test(line); });
var newLines = lines.map(function (line, i) {
return allNumbered ? line.replace(re, "") : (i + 1) + ". " + line.replace(re, "");
});
replaceBlock(textarea, range, newLines.join("\n"));
}
function toggleHeadingPlain(textarea) {
var range = currentLineRange(textarea);
var re = /^#{1,6}\s/;
var newBlock = re.test(range.block) ? range.block.replace(re, "") : "### " + range.block;
replaceBlock(textarea, range, newBlock);
}
function toggleCodePlain(textarea) {
var start = textarea.selectionStart;
var end = textarea.selectionEnd;
var selected = textarea.value.slice(start, end);
if (selected.indexOf("\n") === -1) {
wrapSelection(textarea, "`");
return;
}
var value = textarea.value;
textarea.value = value.slice(0, start) + "```\n" + selected + "\n```" + value.slice(end);
textarea.selectionStart = start + 4;
textarea.selectionEnd = start + 4 + selected.length;
textarea.focus();
fireInput(textarea);
}
function insertLinkPlain(textarea) {
var start = textarea.selectionStart;
var end = textarea.selectionEnd;
var selected = textarea.value.slice(start, end) || "링크 텍스트";
var url = window.prompt("링크 주소를 입력하세요", "https://");
if (!url) return;
var value = textarea.value;
var markdown = "[" + selected + "](" + url + ")";
textarea.value = value.slice(0, start) + markdown + value.slice(end);
textarea.selectionStart = textarea.selectionEnd = start + markdown.length;
textarea.focus();
fireInput(textarea);
}
// journal_editor_toolbar.html의 버튼들과 journal.html의 카테고리 템플릿 자동 채우기가
// 호출하는 공개 API — 어느 엔진(EasyMDE/순수 textarea)이 붙어있는지는 t._easymde 유무로
// 판단해서 알맞은 쪽으로 위임한다.
window.JournalEditor = {
bold: function (t) { if (t) (t._easymde ? t._easymde.toggleBold() : wrapSelection(t, "**")); },
italic: function (t) { if (t) (t._easymde ? t._easymde.toggleItalic() : wrapSelection(t, "*")); },
strike: function (t) { if (t) (t._easymde ? t._easymde.toggleStrikethrough() : wrapSelection(t, "~~")); },
heading: function (t) { if (t) (t._easymde ? t._easymde.toggleHeadingSmaller() : toggleHeadingPlain(t)); },
quote: function (t) { if (t) (t._easymde ? t._easymde.toggleBlockquote() : toggleLinePrefix(t, "> ")); },
ul: function (t) { if (t) (t._easymde ? t._easymde.toggleUnorderedList() : toggleLinePrefix(t, "- ")); },
ol: function (t) { if (t) (t._easymde ? t._easymde.toggleOrderedList() : toggleOrderedListPlain(t)); },
code: function (t) { if (t) (t._easymde ? t._easymde.toggleCodeBlock() : toggleCodePlain(t)); },
link: function (t) { if (t) (t._easymde ? t._easymde.drawLink() : insertLinkPlain(t)); },
setValue: function (t, value) {
if (!t) return;
t.value = value;
if (t._easymde) t._easymde.value(value);
fireInput(t);
},
};
// ---- 클립보드 이미지 붙여넣기 ----
// 스크린샷/사진을 그대로 붙여넣기 대신 서버에 업로드하고 그 자리에 마크다운 이미지
// 문법(![](url))을 끼워넣는다. 두 엔진 다 업로드 로직은 같고, "지금 커서 위치를 어떻게
// 표시해뒀다가 나중에 찾아서 바꿔치기하는지"만 다르다.
function uploadPastedImage(file) {
var formData = new FormData();
formData.append("file", file, file.name || "pasted-image.png");
return fetch("/api/journal/paste-image", { method: "POST", body: formData }).then(function (res) {
if (!res.ok) return res.json().then(function (body) { throw new Error(body.detail || "업로드 실패"); });
return res.json();
}); });
} }
// 클립보드에 이미지가 있으면(스크린샷/사진 복사 등) 그대로 붙여넣기 대신 서버에 업로드하고 function extractImageFile(event) {
// 그 자리에 마크다운 이미지 문법(![](url))을 끼워넣는다. 이미지가 아니면 그냥 통과시켜서
// CodeMirror 기본 텍스트 붙여넣기가 그대로 동작하게 둔다.
function handleImagePaste(cm, event) {
var items = event.clipboardData && event.clipboardData.items; var items = event.clipboardData && event.clipboardData.items;
if (!items) return; if (!items) return null;
var imageItem = null;
for (var i = 0; i < items.length; i++) { for (var i = 0; i < items.length; i++) {
if (items[i].type.indexOf("image/") === 0) { if (items[i].type.indexOf("image/") === 0) return items[i].getAsFile();
imageItem = items[i];
break;
}
} }
if (!imageItem) return; return null;
}
event.preventDefault(); // 순수 textarea 모드: 업로드 중 표식을 값에서 찾아 최종 링크로 바꾼다. 고정된 커서
var file = imageItem.getAsFile(); // 좌표 대신 텍스트 표식으로 위치를 추적하므로, 업로드가 끝나기 전에 사용자가 다른 곳을
// 계속 타이핑해도 자리를 잃지 않는다.
function handlePlainImagePaste(textarea, event) {
var file = extractImageFile(event);
if (!file) return; if (!file) return;
event.preventDefault();
var marker = "![업로드 중… #" + Math.random().toString(36).slice(2, 8) + "]()";
insertAtCursor(textarea, marker);
uploadPastedImage(file)
.then(function (data) { replaceMarker(textarea, marker, "![](" + data.url + ")"); })
.catch(function (err) { replaceMarker(textarea, marker, "(이미지 붙여넣기 실패: " + err.message + ")"); });
}
// EasyMDE 모드: CodeMirror 북마크로 위치를 추적한다(원본 로직 그대로).
function handleCodeMirrorImagePaste(cm, event) {
var file = extractImageFile(event);
if (!file) return;
event.preventDefault();
var doc = cm.getDoc(); var doc = cm.getDoc();
var from = doc.getCursor(); var from = doc.getCursor();
var placeholder = "![업로드 중...]()"; var placeholder = "![업로드 중...]()";
doc.replaceRange(placeholder, from); doc.replaceRange(placeholder, from);
var to = { line: from.line, ch: from.ch + placeholder.length }; var to = { line: from.line, ch: from.ch + placeholder.length };
// 업로드가 끝나기 전에 사용자가 다른 곳에서 계속 타이핑해도(줄 추가 등) 자리를 잃지
// 않도록 고정 좌표 대신 CodeMirror 북마크로 추적한다.
var startMark = doc.setBookmark(from); var startMark = doc.setBookmark(from);
var endMark = doc.setBookmark(to); var endMark = doc.setBookmark(to);
var formData = new FormData(); uploadPastedImage(file)
formData.append("file", file, file.name || "pasted-image.png");
fetch("/api/journal/paste-image", { method: "POST", body: formData })
.then(function (res) {
if (!res.ok) return res.json().then(function (body) { throw new Error(body.detail || "업로드 실패"); });
return res.json();
})
.then(function (data) { .then(function (data) {
var start = startMark.find(); var start = startMark.find();
var end = endMark.find(); var end = endMark.find();
if (start && end) { doc.replaceRange("![](" + data.url + ")", start, end); } if (start && end) doc.replaceRange("![](" + data.url + ")", start, end);
}) })
.catch(function (err) { .catch(function (err) {
var start = startMark.find(); var start = startMark.find();
var end = endMark.find(); var end = endMark.find();
if (start && end) { doc.replaceRange("(이미지 붙여넣기 실패: " + err.message + ")", start, end); } if (start && end) doc.replaceRange("(이미지 붙여넣기 실패: " + err.message + ")", start, end);
}) })
.finally(function () { .finally(function () {
startMark.clear(); startMark.clear();
@@ -96,6 +234,57 @@
}); });
} }
function initEasyMDE(textarea) {
// 내장 툴바(toolbar: false)는 안 쓴다 — EasyMDE 기본 툴바 아이콘은 Font Awesome CDN을
// 전제로 하는데, 이 앱은 CDN을 안 쓰는 게 원칙이라 대신 journal_editor_toolbar.html의
// 자체 버튼이 위 JournalEditor를 통해 EasyMDE 인스턴스 메서드를 호출한다.
var easymde = new EasyMDE({
element: textarea,
toolbar: false,
spellChecker: false,
autoDownloadFontAwesome: false,
status: false,
placeholder: textarea.getAttribute("placeholder") || "",
minHeight: (textarea.getAttribute("rows") || 6) * 24 + "px",
// 글머리 목록 버튼/Enter 자동 이어쓰기가 기본 "*" 대신 "-"를 쓰게 한다.
// 서버 렌더링(app/markdown_utils.py)은 -/*/+ 전부 동일하게 처리하니 렌더링과는 무관하고
// 순수하게 에디터가 새로 만들어주는 글머리 기호에 대한 취향 설정이다.
unorderedListStyle: "-",
});
// CodeMirror는 원본 textarea와 실시간으로 값이 동기화되지 않는다(.save()를 명시적으로
// 불러야 함) — 매 변경마다 저장하고, htmx 미리보기(hx-trigger="input ...")와
// Alpine x-model이 둘 다 반응하도록 input 이벤트를 합성해서 던진다.
easymde.codemirror.on("change", function () {
easymde.codemirror.save();
fireInput(textarea);
});
easymde.codemirror.on("paste", function (cm, event) {
handleCodeMirrorImagePaste(cm, event);
});
textarea._easymde = easymde;
}
function initPlainTextarea(textarea) {
textarea.addEventListener("paste", function (event) { handlePlainImagePaste(textarea, event); });
}
function initEditors(root) {
var scope = root instanceof Element ? root : document;
var textareas = scope.querySelectorAll("textarea.markdown-editor:not([data-journal-editor-initialized])");
textareas.forEach(function (textarea) {
textarea.setAttribute("data-journal-editor-initialized", "true");
if (typeof EasyMDE !== "undefined" && !isIOS()) {
initEasyMDE(textarea);
} else {
initPlainTextarea(textarea);
}
});
}
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
initEditors(document); initEditors(document);
}); });
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE_NAME = "habit-tracker-v6"; const CACHE_NAME = "habit-tracker-v8";
const APP_SHELL = [ const APP_SHELL = [
"/static/css/style.css", "/static/css/style.css",
"/static/css/vendor/easymde.min.css", "/static/css/vendor/easymde.min.css",
+3
View File
@@ -4,6 +4,8 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>{% block title %}해빗랩{% endblock %}</title> <title>{% block title %}해빗랩{% endblock %}</title>
{# 구글 OAuth 브랜딩 심사는 홈페이지에서 앱 이름과 목적을 확인한다 — 설명 메타 태그가 없으면 "앱의 목적에 관한 설명이 없다"로 반려된다. #}
<meta name="description" content="{% block meta_description %}HabitLab (해빗랩) is a habit tracking web app. 해빗랩은 만들고 싶은 습관과 끊고 싶은 습관을 요일 단위로 관리하는 습관 기록 앱입니다.{% endblock %}" />
<link rel="manifest" href="/static/manifest.json" /> <link rel="manifest" href="/static/manifest.json" />
<meta name="theme-color" content="#d97757" media="(prefers-color-scheme: light)" /> <meta name="theme-color" content="#d97757" media="(prefers-color-scheme: light)" />
@@ -74,6 +76,7 @@
{% block content %}{% endblock %} {% block content %}{% endblock %}
<footer class="app-footer"> <footer class="app-footer">
<a href="/privacy">개인정보처리방침</a> <a href="/privacy">개인정보처리방침</a>
<a href="/terms">서비스 약관</a>
</footer> </footer>
</div> </div>
</body> </body>
+54 -3
View File
@@ -1,10 +1,15 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}해빗랩 · 습관이 만들어지는 진짜 시간{% endblock %} {% block title %}해빗랩 (HabitLab) · 습관이 만들어지는 진짜 시간{% endblock %}
{% block meta_description %}HabitLab (해빗랩) is a habit tracking web app for building good habits and quitting bad ones on a weekly schedule. 해빗랩은 만들고 싶은 습관과 끊고 싶은 습관을 요일 단위로 관리하는 습관 기록 앱입니다. 요일별 습관 등록과 데일리 체크, 월별·주별 기록과 완료율·연속 달성일 통계, 설정한 시각의 습관 알림, 하루를 남기는 일기를 제공합니다.{% endblock %}
{% block content %} {% block content %}
<div class="landing"> <div class="landing">
<div class="landing-hero"> <div class="landing-hero">
<div class="landing-brand"><img src="/static/icons/icon-192.png" alt="" /> 해빗랩</div> {# 앱 이름은 h1의 순수 텍스트이면서 화면에서 가장 큰 글자여야 한다 — 구글 브랜딩 심사가 OAuth 동의 화면의
<h1>습관이 만들어지는<br />진짜 시간</h1> 앱 이름과 홈페이지의 앱 이름을 대조하는데, 태그라인이 이름보다 크면 그쪽을 사이트 이름으로 본다.
로고는 이름 추출을 방해하지 않도록 h1 바깥에 둔다. #}
<img class="landing-logo" src="/static/icons/icon-192.png" alt="" />
<h1 class="landing-brand">해빗랩 (HabitLab)</h1>
<p class="landing-hero-title">습관이 만들어지는 진짜 시간</p>
<p class="landing-tagline">21일의 법칙 대신, 습관마다 다른 진짜 목표 기간을 알려드려요</p> <p class="landing-tagline">21일의 법칙 대신, 습관마다 다른 진짜 목표 기간을 알려드려요</p>
<div class="landing-streak" aria-hidden="true"> <div class="landing-streak" aria-hidden="true">
@@ -40,6 +45,17 @@
</p> </p>
</div> </div>
<div class="landing-section">
<h2>해빗랩이 하는 일</h2>
<ul class="landing-list">
<li><strong>요일별 습관 등록</strong> — 만들고 싶은 습관과 끊고 싶은 습관을 요일 단위로 등록하고, 습관마다 목표 기간과 달성 조건을 정합니다.</li>
<li><strong>데일리 체크</strong> — 오늘 예정된 습관을 한 화면에서 체크하고, 놓친 습관은 실패로 표시해 솔직하게 기록합니다.</li>
<li><strong>기록과 통계</strong> — 월별 달력과 주별 매트릭스로 지나온 기록을 돌아보고, 습관별 완료율과 연속 달성일을 확인합니다.</li>
<li><strong>습관 알림</strong> — 습관마다 설정한 시각에 브라우저 푸시 알림을 보내드립니다. 알림을 켠 경우에만 동작합니다.</li>
<li><strong>일기</strong> — 하루의 회고나 습관과 무관한 자유 일기를 카테고리·태그·사진과 함께 남길 수 있습니다.</li>
</ul>
</div>
<div class="landing-features"> <div class="landing-features">
<div class="landing-feature"> <div class="landing-feature">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" /><circle cx="12" cy="12" r="5" /><circle cx="12" cy="12" r="1" /></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" /><circle cx="12" cy="12" r="5" /><circle cx="12" cy="12" r="1" /></svg>
@@ -56,5 +72,40 @@
</div> </div>
<a href="/auth/google/login" class="btn btn-primary btn-block">Google로 시작하기</a> <a href="/auth/google/login" class="btn btn-primary btn-block">Google로 시작하기</a>
<div class="landing-section">
<h2>계정과 로그인</h2>
<p class="landing-note">
해빗랩은 구글 계정으로만 로그인합니다. 로그인 시 구글로부터 <strong>이름, 이메일 주소, 프로필 사진</strong>을 받아
계정을 식별하고 화면에 표시하는 용도로만 사용하며, 별도의 비밀번호는 수집하지 않습니다.
Gmail, 드라이브, 캘린더 등 다른 구글 서비스의 데이터에는 접근하지 않습니다.
직접 입력한 습관·기록·일기는 본인 계정에만 저장되고 다른 이용자에게 공개되지 않으며,
로그인 후 계정 페이지에서 언제든 계정과 모든 데이터를 직접 삭제할 수 있습니다.
</p>
<p class="landing-note">
자세한 내용은 <a href="/privacy">개인정보처리방침</a><a href="/terms">서비스 약관</a>을 확인해주세요.
문의: <a href="mailto:shinalok357@gmail.com">shinalok357@gmail.com</a>
</p>
</div>
{# 영문 요약 — 구글 브랜딩 심사의 자동 검사가 한글만으로는 앱 목적을 인식하지 못하는 것으로 보여 병기한다. #}
<div class="landing-section" lang="en">
<h2>About HabitLab (해빗랩)</h2>
<p class="landing-note">
<strong>HabitLab (해빗랩)</strong> is a personal habit tracking web app. It helps you build the habits
you want and quit the ones you don't, on a per-weekday schedule.
</p>
<p class="landing-note">
You can register habits for specific days of the week with a target period based on published research
(Lally et al., 2010, UCL), check them off daily, review your history on monthly and weekly views with
completion rates and streaks, receive browser push reminders at a time you choose, and keep a journal.
</p>
<p class="landing-note">
HabitLab uses Google Sign-In. It receives only your name, email address, and profile picture from Google
to identify your account, and does not access Gmail, Drive, Calendar, or any other Google service data.
See our <a href="/privacy">Privacy Policy</a> and <a href="/terms">Terms of Service</a>.
Contact: <a href="mailto:shinalok357@gmail.com">shinalok357@gmail.com</a>
</p>
</div>
</div> </div>
{% endblock %} {% endblock %}
+8 -8
View File
@@ -134,10 +134,7 @@
if (this.content === this.lastAutoFilled) { if (this.content === this.lastAutoFilled) {
this.content = tmpl; this.content = tmpl;
this.lastAutoFilled = tmpl; this.lastAutoFilled = tmpl;
this.$nextTick(() => { this.$nextTick(() => { JournalEditor.setValue(this.$refs.contentField, tmpl); });
if (this.$refs.contentField._easymde) { this.$refs.contentField._easymde.value(tmpl); }
this.$refs.contentField.dispatchEvent(new Event('input'));
});
} }
}, },
init() { this.onCategoryChange(this.$refs.categorySelect.value); }, init() { this.onCategoryChange(this.$refs.categorySelect.value); },
@@ -252,16 +249,19 @@
{% for d in week %} {% for d in week %}
{% set day_summary = summary_map.get(d) %} {% set day_summary = summary_map.get(d) %}
<div <div
class="calendar-cell clickable{{ '' if d.month == month else ' muted' }}" class="calendar-cell calendar-cell-journal clickable{{ '' if d.month == month else ' muted' }}"
hx-get="/journal/day/{{ d.isoformat() }}" hx-get="/journal/day/{{ d.isoformat() }}"
hx-target="#day-detail" hx-target="#day-detail"
hx-swap="innerHTML" hx-swap="innerHTML"
> >
<span class="calendar-date">{{ d.day }}</span> <span class="calendar-date">{{ d.day }}</span>
{% if day_summary %} {% if day_summary %}
<span class="journal-day-dots"> <span class="journal-day-entries">
{% for color in day_summary.category_colors %} {% for entry in day_summary.entries %}
<span class="journal-day-dot" style="background: {{ color }};"></span> <span class="journal-day-entry">
<span class="journal-day-dot" style="background: {{ entry.color }};"></span>
<span class="journal-day-title">{{ entry.title }}</span>
</span>
{% endfor %} {% endfor %}
</span> </span>
{% endif %} {% endif %}
@@ -1,14 +1,14 @@
<div class="markdown-toolbar"> <div class="markdown-toolbar">
<span x-show="!previewMode" style="display:flex; gap:4px; flex-wrap:wrap;"> <span x-show="!previewMode" style="display:flex; gap:4px; flex-wrap:wrap;">
<button type="button" class="md-tool-btn" title="굵게" @click="$el.closest('.field').querySelector('textarea')._easymde?.toggleBold()"><strong>B</strong></button> <button type="button" class="md-tool-btn" title="굵게" @click="JournalEditor.bold($el.closest('.field').querySelector('textarea'))"><strong>B</strong></button>
<button type="button" class="md-tool-btn" title="기울임" @click="$el.closest('.field').querySelector('textarea')._easymde?.toggleItalic()"><em>I</em></button> <button type="button" class="md-tool-btn" title="기울임" @click="JournalEditor.italic($el.closest('.field').querySelector('textarea'))"><em>I</em></button>
<button type="button" class="md-tool-btn" title="취소선" @click="$el.closest('.field').querySelector('textarea')._easymde?.toggleStrikethrough()"><s>S</s></button> <button type="button" class="md-tool-btn" title="취소선" @click="JournalEditor.strike($el.closest('.field').querySelector('textarea'))"><s>S</s></button>
<button type="button" class="md-tool-btn" title="제목" @click="$el.closest('.field').querySelector('textarea')._easymde?.toggleHeadingSmaller()">H</button> <button type="button" class="md-tool-btn" title="제목" @click="JournalEditor.heading($el.closest('.field').querySelector('textarea'))">H</button>
<button type="button" class="md-tool-btn" title="인용" @click="$el.closest('.field').querySelector('textarea')._easymde?.toggleBlockquote()"></button> <button type="button" class="md-tool-btn" title="인용" @click="JournalEditor.quote($el.closest('.field').querySelector('textarea'))"></button>
<button type="button" class="md-tool-btn" title="글머리 목록" @click="$el.closest('.field').querySelector('textarea')._easymde?.toggleUnorderedList()"></button> <button type="button" class="md-tool-btn" title="글머리 목록" @click="JournalEditor.ul($el.closest('.field').querySelector('textarea'))"></button>
<button type="button" class="md-tool-btn" title="번호 목록" @click="$el.closest('.field').querySelector('textarea')._easymde?.toggleOrderedList()">1.</button> <button type="button" class="md-tool-btn" title="번호 목록" @click="JournalEditor.ol($el.closest('.field').querySelector('textarea'))">1.</button>
<button type="button" class="md-tool-btn" title="코드" @click="$el.closest('.field').querySelector('textarea')._easymde?.toggleCodeBlock()">&lt;/&gt;</button> <button type="button" class="md-tool-btn" title="코드" @click="JournalEditor.code($el.closest('.field').querySelector('textarea'))">&lt;/&gt;</button>
<button type="button" class="md-tool-btn" title="링크" @click="$el.closest('.field').querySelector('textarea')._easymde?.drawLink()">🔗</button> <button type="button" class="md-tool-btn" title="링크" @click="JournalEditor.link($el.closest('.field').querySelector('textarea'))">🔗</button>
</span> </span>
<button <button
type="button" type="button"
+47
View File
@@ -0,0 +1,47 @@
{% extends "base.html" %}
{% block title %}서비스 약관 · 해빗랩{% endblock %}
{% block meta_description %}해빗랩 서비스 약관 — 습관 기록 앱 해빗랩의 이용 조건, 계정, 이용자의 콘텐츠, 서비스 중단 및 책임 범위를 안내합니다.{% endblock %}
{% block content %}
<div style="max-width: 640px; margin: 0 auto; padding: 24px 16px 48px;">
<div class="card">
<h1>서비스 약관</h1>
<p>시행일자: 2026년 8월 8일</p>
<p>이 약관은 해빗랩(이하 "서비스")의 이용 조건을 정합니다. 서비스에 로그인하면 이 약관에 동의한 것으로 봅니다.</p>
<h2>1. 서비스 소개</h2>
<p>해빗랩은 이용자가 만들고 싶은 습관과 끊고 싶은 습관을 요일 단위로 등록하고, 매일 체크하며, 월별·주별 기록과 완료율·연속 달성일을 확인하고, 설정한 시각에 알림을 받고, 하루의 일기를 남길 수 있는 개인용 습관 기록 서비스입니다. 서비스는 무료로 제공되며 유료 결제 항목이 없습니다.</p>
<h2>2. 계정</h2>
<p>서비스는 구글 계정을 통한 로그인만 지원합니다. 이용자는 본인의 구글 계정으로 로그인해야 하며, 계정 접근 권한의 관리 책임은 이용자 본인에게 있습니다. 이용자는 로그인 후 계정 페이지에서 언제든지 계정과 모든 데이터를 직접 삭제할 수 있으며, 삭제 시 습관·체크 기록·일기·첨부파일·알림 구독 정보가 함께 삭제되고 복구할 수 없습니다.</p>
<h2>3. 이용자의 콘텐츠</h2>
<p>이용자가 서비스에 입력한 습관, 체크 기록, 일기, 첨부한 사진·영상 등 모든 콘텐츠의 권리는 이용자에게 있습니다. 서비스는 이 콘텐츠를 이용자에게 서비스를 제공하기 위한 목적(저장, 조회, 통계 계산, 알림 발송)으로만 처리하며, 광고·마케팅에 이용하거나 이용자의 동의 없이 제3자에게 제공하지 않습니다. 각 이용자의 데이터는 계정 단위로 분리되어 다른 이용자가 접근할 수 없습니다.</p>
<h2>4. 금지 행위</h2>
<p>이용자는 다음 행위를 해서는 안 됩니다.</p>
<p>가. 타인의 계정에 무단으로 접근하거나 접근을 시도하는 행위<br />
나. 서비스의 정상적인 운영을 방해하는 행위(비정상적인 대량 요청, 취약점 악용 등)<br />
다. 법령을 위반하거나 타인의 권리를 침해하는 콘텐츠를 저장·유포하는 행위</p>
<h2>5. 알림</h2>
<p>습관 알림(Web Push)은 이용자가 브라우저에서 알림 권한을 허용하고 직접 켠 경우에만 발송됩니다. 알림은 브라우저와 운영체제, 네트워크 상황에 따라 지연되거나 전달되지 않을 수 있으며, 서비스는 알림의 정시 도달을 보장하지 않습니다. 알림은 브라우저 설정이나 서비스 내에서 언제든 끌 수 있습니다.</p>
<h2>6. 서비스의 변경과 중단</h2>
<p>서비스는 개인이 운영하는 무료 서비스로, 기능이 변경되거나 점검·장애·운영 중단이 발생할 수 있습니다. 서비스 전체를 종료하는 경우에는 이용자가 데이터를 보관할 수 있도록 서비스 내 공지 또는 이메일로 사전에 안내합니다.</p>
<h2>7. 책임의 범위</h2>
<p>서비스는 습관 기록을 돕는 도구일 뿐이며, 의료·건강·심리 상담 등 전문적인 조언을 제공하지 않습니다. 건강과 관련된 결정은 반드시 전문가와 상의하시기 바랍니다. 서비스는 데이터 백업과 보전을 위해 합리적인 노력을 기울이지만, 무료로 제공되는 서비스의 성격상 데이터 손실이나 서비스 이용으로 발생한 손해에 대해 법령이 허용하는 범위에서 책임을 지지 않습니다.</p>
<h2>8. 개인정보</h2>
<p>개인정보의 수집·이용·보관에 관한 사항은 <a href="/privacy">개인정보처리방침</a>을 따릅니다.</p>
<h2>9. 약관의 변경</h2>
<p>이 약관은 법령이나 서비스 변경사항을 반영하기 위해 수정될 수 있으며, 변경 시 이 페이지를 통해 시행일자와 함께 고지합니다.</p>
<h2>10. 문의처</h2>
<p>해빗랩 운영자<br />
이메일: <a href="mailto:shinalok357@gmail.com">shinalok357@gmail.com</a></p>
</div>
</div>
{% endblock %}
+1 -1
View File
@@ -285,7 +285,7 @@ def test_get_monthly_journal_summary_counts_entries_per_day(db_session, test_use
summary = journal_service.get_monthly_journal_summary(db_session, test_user.id, today.year, today.month) summary = journal_service.get_monthly_journal_summary(db_session, test_user.id, today.year, today.month)
assert summary[today].total_count == 2 assert summary[today].total_count == 2
assert summary[today].category_colors == ["#ff0000"] assert [e.color for e in summary[today].entries] == ["#ff0000", "#ff0000"]
def test_get_day_entries_returns_entries_for_that_date(db_session, test_user): def test_get_day_entries_returns_entries_for_that_date(db_session, test_user):