write.html과 edit.html에 글자 수를 표시할 작은 UI(HTML)를 추가JavaScript 코드를 추가<div class="mb-5">
<div class="d-flex justify-content-between align-items-end mb-2">
<label class="form-label fw-bold text-secondary mb-0">본문 내용</label>
<button type="button"
class="btn btn-sm btn-warning fw-bold rounded-pill px-3 shadow-sm text-dark"
data-bs-toggle="modal" data-bs-target="#aiToneModal">
<i class="bi bi-magic me-1"></i> AI로 글 다듬기
</button>
</div>
<div id="editor"></div>
</div>
——————————————————————————————————————[비교]—————————————————————————————————————————
<div class="mb-5">
<div class="d-flex justify-content-between align-items-end mb-2">
<label class="form-label fw-bold text-secondary mb-0">본문 내용</label>
<div class="d-flex align-items-center gap-3">
<span class="text-muted small fw-bold">
글자 수: <span id="text-count" class="text-success">0</span>자
</span>
<button type="button" class="btn btn-sm btn-warning fw-bold rounded-pill px-3 shadow-sm text-dark" data-bs-toggle="modal" data-bs-target="#aiToneModal">
<i class="bi bi-magic me-1"></i> AI로 글 다듬기
</button>
</div>
</div>
<div id="editor"></div>
</div>
write.html 하단 <script> 내부editor.on('change') 이벤트를 사용하면// '글자 수 카운트' 최적화 로직
// HTML에서 글자 숫자가 표시될 요소를 DOM에서 찾아 변수에 저장합니다.
const textCountElement = document.getElementById('text-count');
// Toast UI 에디터의 내장 이벤트인 'change' 이벤트를 구독합니다.
// 키보드를 칠 때마다 매번 실행됩니다.
editor.on('change', function () {
// 에디터 안에 입력된 마크다운 텍스트 원본을 가져옵니다.
let text = editor.getMarkdown();
// 공백(스페이스바, 엔터)을 글자 수에서 제외하려면 공백을 지워줍니다.
// 정규식 /\s/g 는 모든 종류의 공백(띄어쓰기, 줄바꿈 등)을 의미합니다.
// (만약 공백을 포함하고 싶다면 아래 replace 줄을 삭제하시면 됩니다.)
let textWithoutSpace = text.replace(/\s/g, '');
// 공백이 제거된 순수 글자의 길이를 구합니다.
let length = textWithoutSpace.length;
// 계산된 길이를 화면의 HTML 요소에 텍스트로 삽입하여 업데이트합니다.
textCountElement.innerText = length;
// UX 향상을 위해 글자 수가 0일 때는 회색, 1자 이상일 때는 초록색으로 바꿉니다.
if (length > 0) {
// 글자가 있으면 초록색 클래스(text-success)를 추가합니다.
textCountElement.classList.add('text-success');
// 회색 클래스는 제거합니다.
textCountElement.classList.remove('text-secondary');
} else {
// 글자가 없으면 회색 클래스(text-secondary)를 추가합니다.
textCountElement.classList.add('text-secondary');
// 초록색 클래스는 제거합니다.
textCountElement.classList.remove('text-success');
}
});

setInterval을 사용해 1~3분마다 현재 작성 중인 글을write.html에 존재하는 savePost(true) 로직을 재활용deleted_at (삭제된 날짜)가Hard Delete)하는 로직을 작성deleted_at이 30일 지난 데이터를 DB에서 hard delete 하는 배치구현alert("임시저장 되었습니다")가 뜨면 글쓰기 흐름이 끊김// [1] 전역 변수 선언: 자동 임시저장을 최적화하기 위한 변수들입니다.
// 최초 임시저장 시 백엔드(Django)에서 발급받은 게시글의 고유 PK(id)를 저장할 변수입니다. (초기값은 비어있음)
let currentTempPostId = null;
// 내용이 변경되었을 때만 서버에 요청을 보내기 위해, 가장 마지막에 DB에 저장된 글의 내용을 기억하는 변수입니다.
let lastSavedContent = "";
// [2] 5분(300,000밀리초)마다 반복해서 실행되는 자동 임시저장 타이머를 설정합니다.
// 브라우저 백그라운드에서 비동기(async)로 조용히 실행됩니다.
setInterval(async () => {
// 사용자가 Toast UI 에디터에 작성한 마크다운 텍스트 원본을 가져옵니다.
const currentContent = editor.getMarkdown();
// 사용자가 제목 입력칸(id="title")에 입력한 값을 가져옵니다.
const title = document.getElementById('title').value;
// 만약 제목이나 내용이 아예 없거나,
// 혹은 방금 전(5분 전)에 저장했던 내용과 현재 내용이 100% 똑같다면 (사용자가 글을 쓰지 않고 켜두기만 했다면)
if (!title || !currentContent || currentContent === lastSavedContent) {
// 서버에 쓸데없는 API 요청(트래픽)을 보내지 않고 함수를 즉시 종료(return)합니다.
// -> 백엔드 부하를 막아주는 프론트엔드 최선의 방어 로직입니다!
return;
}
// 서버로 보낼 데이터를 DRF Serializer가 기대하는 JSON 포맷(객체) 형태로 포장합니다.
const payload = {
title: title, // 현재 작성 중인 제목
content: currentContent, // 현재 작성 중인 본문 내용
is_temp: true, // 임시저장 여부를 판단하는 boolean 값 (무조건 true)
visibility: 'PRIVATE' // 임시저장 글은 타인에게 노출되면 안 되므로 기본값을 비공개로 설정합니다.
};
try {
// currentTempPostId가 비어있다면(null) 처음 저장하는 것이므로 POST 엔드포인트를,
// 이미 PK가 있다면 덮어써야 하므로 PUT 엔드포인트(id 포함)를 삼항 연산자로 동적 할당합니다.
const url = currentTempPostId ? `/api/v1/post/${currentTempPostId}/` : '/api/v1/post/';
// HTTP 메서드 또한 PK 유무에 따라 PUT(수정/덮어쓰기) 또는 POST(신규 생성)로 분기합니다.
const method = currentTempPostId ? 'PUT' : 'POST';
// 결정된 URL과 메서드를 사용하여 Django 백엔드 서버에 비동기(fetch) 통신을 요청합니다.
const response = await fetch(url, {
method: method, // 동적으로 결정된 HTTP 메서드 (POST or PUT)
headers: {
'Content-Type': 'application/json', // 전송하는 데이터가 JSON 형식임을 백엔드에 명시합니다.
'Authorization': 'Bearer ' + localStorage.getItem('access_token') // JWT 토큰을 헤더에 실어 인증을 통과합니다.
},
body: JSON.stringify(payload) // 자바스크립트 객체(payload)를 JSON 문자열로 변환하여 본문(body)에 담아 보냅니다.
});
// HTTP 상태 코드가 200번대(성공)로 떨어졌다면 실행됩니다.
if (response.ok) {
// 백엔드가 보내준 응답 데이터(JSON)를 자바스크립트 객체로 파싱합니다.
const data = await response.json();
// 처음 POST 요청으로 임시글을 생성했다면, 백엔드가 돌려준 새로운 게시글의 PK(id)를 전역 변수에 저장합니다.
// -> 이제 다음 5분 뒤부터는 이 PK를 사용해 새로운 글을 생성하지 않고 기존 글을 PUT(수정)하게 됩니다!
if (!currentTempPostId && data.id) {
currentTempPostId = data.id;
}
// 다음 5분 뒤 비교 연산을 위해, 방금 성공적으로 DB에 저장한 내용을 '마지막 저장 내용' 변수에 덮어씌웁니다.
lastSavedContent = currentContent;
// 사용자에게 저장이 완료되었음을 시각적으로 알리기 위해 현재 시간을 구합니다.
const now = new Date();
// 시(Hours)와 분(Minutes)을 추출하여 두 자리 숫자(예: 09:05)로 예쁘게 포맷팅합니다.
const timeString = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`;
// 화면 우측 하단이나 상단에 몰래(?) 저장 시간을 텍스트로 업데이트해 줍니다.
// (경고창 alert를 띄우면 글을 쓰다가 흐름이 끊기므로 절대 금물입니다!)
const timeDisplay = document.getElementById('auto-save-time');
if (timeDisplay) {
timeDisplay.innerText = `마지막 자동 저장: ${timeString}`;
}
}
} catch (error) {
// 사용자가 눈치채지 못하게 백그라운드에서 돌아가는 기능이므로, 에러가 나더라도 경고창 대신 개발자 도구 콘솔에만 기록합니다.
console.error('자동 임시저장 실패:', error);
}
// 타이머의 간격을 설정
// 1분(60000) -> 5분(300000)으로 변경했습니다. (만약 10분으로 하시려면 600000을 입력하시면 됩니다.)
}, 300000);
<div class="d-flex justify-content-end gap-2 mt-4 pt-3 border-top">
<a href="/" class="btn btn-light rounded-pill px-4">취소</a>
<button type="button" id="tempSaveBtn" class="btn btn-outline-secondary rounded-pill px-4">
임시저장
</button>
<button type="submit" class="btn btn-primary rounded-pill px-5 shadow">발행하기</button>
</div>
</form>
——————————————————————————————————————[비교]—————————————————————————————————————————
<div class="d-flex justify-content-between align-items-center mt-4 pt-3 border-top">
<span id="auto-save-time" class="text-muted small fw-bold ms-2"></span>
<div class="d-flex gap-2">
<a href="/" class="btn btn-light rounded-pill px-4">취소</a>
<button type="button" id="tempSaveBtn" class="btn btn-outline-secondary rounded-pill px-4">
임시저장
</button>
<button type="submit" class="btn btn-primary rounded-pill px-5 shadow">발행하기</button>
</div>
</div>
</form>
<div class="d-flex justify-content-end gap-2 mt-4 pt-3 border-top">
<button type="button" onclick="history.back()" class="btn btn-light rounded-pill px-4">취소</button>
<button type="submit" class="btn btn-primary rounded-pill px-5 shadow">수정 완료</button>
</div>
</form>
——————————————————————————————————————[비교]—————————————————————————————————————————
<div class="d-flex justify-content-between align-items-center mt-4 pt-3 border-top">
<span id="auto-save-time" class="text-muted small fw-bold ms-2"></span>
<div class="d-flex gap-2">
<button type="button" onclick="history.back()" class="btn btn-light rounded-pill px-4">취소</button>
<button type="submit" class="btn btn-primary rounded-pill px-5 shadow">수정 완료</button>
</div>
</div>
</form>
// 기존 데이터 불러오는 곳 수정
// 🌟 1. 자동 저장을 제어할 전역 변수 2개를 추가합니다.
let isOriginalTemp = false; // 이 글이 원래 임시저장 글이었는지 확인하는 변수
let lastSavedContent = ""; // 변경사항 비교를 위한 변수
document.addEventListener("DOMContentLoaded", async () => {
// ... (editor 세팅 코드 생략) ...
// 2. 기존 게시글 데이터 불러오기 로직 (여기 안쪽을 수정합니다)
try {
const res = await fetch(`/api/v1/post/${currentPostId}/`);
const data = await res.json();
if (res.ok) {
document.getElementById('title').value = data.title;
document.getElementById('visibility').value = data.visibility;
// ... 생략 ...
editor.setMarkdown(data.content);
// 🌟 2. 백엔드에서 받아온 데이터로 전역 변수를 세팅합니다.
isOriginalTemp = data.is_temp === true;
lastSavedContent = data.content;
}
} catch (e) {
console.error(e);
}
———————————————————————————————————————————————————————————————————————————————
// 타이머 로직 맨 밑에 추가
setInterval(async () => {
const currentContent = editor.getMarkdown();
const title = document.getElementById('title').value;
// 내용이 비어있거나 변경사항이 없으면 즉시 종료
if (!title || !currentContent || currentContent === lastSavedContent) return;
const timeDisplay = document.getElementById('auto-save-time');
const now = new Date();
const timeString = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`;
// 💡 케이스 A: 이미 발행된 일반 글인 경우 (서버 전송 X, 로컬 백업 O)
if (!isOriginalTemp) {
// 브라우저 로컬 스토리지에 글 번호와 함께 안전하게 백업해둡니다.
localStorage.setItem(`backup_post_${currentPostId}`, currentContent);
if(timeDisplay) {
timeDisplay.innerHTML = `<i class="bi bi-shield-check text-success"></i> 브라우저 안전 백업: ${timeString}`;
}
lastSavedContent = currentContent;
return; // 여기서 함수를 끝내어 서버로 요청이 가지 않게 막습니다!
}
// 💡 케이스 B: 아직 발행되지 않은 임시저장 글인 경우 (서버 전송 O)
const payload = {
title: title,
content: currentContent,
is_temp: true,
visibility: document.getElementById('visibility').value,
tags: document.getElementById('tags').value.split(',').map(tag => tag.trim()).filter(tag => tag !== ""),
};
try {
// 이미 존재하는 임시글이므로 무조건 PUT으로 덮어씁니다.
const response = await fetch(`/api/v1/post/${currentPostId}/`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token
},
body: JSON.stringify(payload)
});
if (response.ok) {
lastSavedContent = currentContent;
if(timeDisplay) {
timeDisplay.innerText = `마지막 자동 저장: ${timeString}`;
}
}
} catch (error) {
console.error('자동 임시저장(수정) 실패:', error);
}
}, 300000); // 5분(300000ms) 주기
}); // 🚨 DOMContentLoaded 닫는 괄호 (이 위쪽에 붙여넣으세요!)
// 기존의 '수정 완료' submit 이벤트 코드는 그대로 유지합니다.
document.getElementById('editForm').addEventListener('submit', async (e) => { ...
"이전에 쓰다 만 백업본이 있습니다. 불러오시겠습니까?" 하고 물어보는 기능// 🌟 [추가된 최적화 코드] 로컬 백업 복구 로직
// ========================================================
// 브라우저 로컬 스토리지에 이 글의 백업본이 남아있는지 확인합니다.
const backupContent = localStorage.getItem(`backup_post_${currentPostId}`);
// 만약 백업본이 존재하고, 그 백업본이 방금 서버에서 불러온 원본 내용과 다를 경우에만 물어봅니다.
// (같으면 굳이 물어볼 필요가 없으니까요!)
if (backupContent && backupContent !== data.content) {
// confirm 창을 띄워 사용자의 의사를 물어봅니다. (확인=true, 취소=false 반환)
const wantsToRestore = confirm("이전에 수정하다 만 백업본이 있습니다. 불러오시겠습니까?\n(취소 시 기존 백업본은 영구 삭제됩니다.)");
if (wantsToRestore) {
// [확인]을 누르면, 에디터의 내용을 로컬 백업본으로 싹 덮어씌웁니다.
editor.setMarkdown(backupContent);
// 다음 5분 뒤 비교를 위해, 현재 내용도 백업본으로 갱신해 줍니다.
lastSavedContent = backupContent;
} else {
// [취소]를 누르면, 백업본이 필요 없다는 뜻이므로 로컬 스토리지에서 깨끗하게 지워줍니다.
localStorage.removeItem(`backup_post_${currentPostId}`);
}
}
if (res.ok) {
alert("성공적으로 수정되었습니다.");
window.location.href = `/api/v1/post/${currentPostId}/page/`;
} else {
alert("수정에 실패했습니다.");
}
——————————————————————————————————————[비교]—————————————————————————————————————————
if (res.ok) {
// 서버에 수정 완료(발행)가 무사히 끝났으므로, 더 이상 쓸모없는 로컬 백업을 깔끔하게 지워줍니다.
localStorage.removeItem(`backup_post_${currentPostId}`);
alert("성공적으로 수정되었습니다.");
window.location.href = `/api/v1/post/${currentPostId}/page/`;
} else {
alert("수정에 실패했습니다.");
}


user/models/user.py의 User 모델에 profile_img 필드가 존재post/models/post.py의 Post 모델에 --