DailyByte
일상과 연결되는 AI
DailyByte
일상과 연결되는 AI
← 전체 코드 목록
📖 사용 방법
구글 시트를 데이터베이스로 활용하여, 나만의 스티커 메모를 만들고, 이동하고, 편집하며 실시간으로 저장되는 웹 앱을 직접 만들어 보겠습니다.
이런 분께 추천해요
- 복잡한 프로그램 설치 없이 웹에서 나만의 메모장을 만들고 싶은 분
- 구글 시트(Google Sheets)를 활용해 데이터를 저장하고 관리하는 방법을 배우고 싶은 분
- 코딩 지식이 없어도 웹 앱(Web App)을 직접 만들어 보고 싶은 분
- 아이디어나 할 일 목록을 자유롭게 정리하고 싶은 분
완성하면 이렇게 됩니다
이 가이드를 따라 하면, 웹 브라우저에서 사용할 수 있는 나만의 스티커 메모 보드가 완성됩니다.
- 새 메모 추가: '+ 새 메모' 버튼을 눌러 새로운 스티커 메모를 만들 수 있습니다.
- 자유로운 배치: 메모를 드래그(끌어서 옮기기)하여 원하는 위치에 놓을 수 있습니다.
- 크기 조절: 메모 오른쪽 아래 모서리를 드래그하여 크기를 자유롭게 조절할 수 있습니다.
- 내용 편집: 메모를 클릭하여 내용을 입력하고 수정할 수 있습니다. 텍스트
💻 코드
const SHEET_NAME = 'Notes'; // 메모 저장용 시트 이름
function getSheet_() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
let sheet = ss.getSheetByName(SHEET_NAME);
if (!sheet) sheet = ss.insertSheet(SHEET_NAME);
// 헤더: id, x, y, w, h, text(HTML), color, z
const header = ['id', 'x', 'y', 'w', 'h', 'text', 'color', 'z'];
const firstRow = sheet.getRange(1, 1, 1, header.length).getValues()[0];
const isHeaderSet = firstRow.some(v => v);
if (!isHeaderSet) {
sheet.getRange(1, 1, 1, header.length).setValues([header]);
}
return sheet;
}
/** 웹앱 화면 */
function doGet(e) {
return HtmlService.createHtmlOutputFromFile('index')
.setTitle('스티커 메모 보드');
}
/** 시트에서 메모 목록 불러오기 */
function getNotes() {
const sheet = getSheet_();
const values = sheet.getDataRange().getValues();
if (values.length < 2) return [];
const notes = [];
for (let i = 1; i < values.length; i++) {
const row = values[i];
if (!row[0]) continue; // id 없으면 스킵
const note = {
id: Number(row[0]),
x: Number(row[1] || 80),
y: Number(row[2] || 80),
w: Number(row[3] || 260),
h: Number(row[4] || 160),
// text에는 HTML 그대로 저장
text: String(row[5] || ''),
color: String(row[6] || 'yellow'),
z: Number(row[7] || 1),
};
notes.push(note);
}
return notes;
}
/** 메모 전체를 시트에 저장 (덮어쓰기) */
function saveNotes(notes) {
const sheet = getSheet_();
if (!Array.isArray(notes)) notes = [];
// 기존 내용 삭제
sheet.clearContents();
// 헤더 다시 쓰기
const header = ['id', 'x', 'y', 'w', 'h', 'text', 'color', 'z'];
sheet.getRange(1, 1, 1, header.length).setValues([header]);
if (!notes.length) return 'OK';
const rows = notes.map(n => [
n.id || new Date().getTime(),
n.x || 80,
n.y || 80,
n.w || 260,
n.h || 160,
n.text || '',
n.color || 'yellow',
n.z || 1,
]);
sheet.getRange(2, 1, rows.length, header.length).setValues(rows);
return 'OK';
}
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<base target="_top">
<title>스티커 메모 보드</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0;
height: 100vh;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f3f5fb;
}
#toolbar {
position: fixed;
top: 10px;
left: 10px;
z-index: 1000;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.9);
border-radius: 8px;
box-shadow: 0 2px 6px rgba(0,0,0,0.15);
font-size: 13px;
}
#toolbar button {
border: none;
padding: 6px 10px;
border-radius: 4px;
cursor: pointer;
background: #3b82f6;
color: #fff;
font-size: 12px;
}
#toolbar button:hover {
opacity: 0.9;
}
#status {
font-size: 11px;
color: #555;
}
/* 메모 카드 */
.note {
position: absolute;
width: 260px;
min-width: 160px;
min-height: 160px;
border-radius: 8px; /* ↔ 크기조절 핸들과 동일 라운딩 */
box-shadow: 0 10px 25px rgba(0,0,0,0.2);
overflow: hidden;
}
.note-header {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 26px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 6px;
font-size: 11px;
cursor: move;
user-select: none;
opacity: 0.9;
}
.note-header .title {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.note-header .actions {
display: flex;
align-items: center;
gap: 4px;
}
.note-header select {
font-size: 10px;
}
.note-header button {
border: none;
background: transparent;
cursor: pointer;
font-size: 14px;
line-height: 1;
}
/* 상단 서식 툴바 */
.note-toolbar {
position: absolute;
top: 26px;
left: 0;
right: 0;
height: 26px;
display: flex;
align-items: center;
gap: 4px;
padding: 0 6px;
font-size: 11px;
background: rgba(255,255,255,0.4);
user-select: none;
}
.note-toolbar button {
border: none;
background: transparent;
cursor: pointer;
padding: 2px 4px;
border-radius: 4px;
font-size: 12px;
}
.note-toolbar button:hover {
background: rgba(0,0,0,0.08);
}
/* 내용 영역: 헤더(26) + 툴바(26) 위,
아래쪽은 크기조절 핸들(14px) + 여유 4px = 18px 만큼 비워둠 */
.note-content {
position: absolute;
top: 52px; /* 26 + 26 */
left: 0;
right: 0;
bottom: 18px; /* ↔ 핸들 바로 위까지만 편집 영역 */
padding: 8px 10px 4px 10px;
font-size: 13px;
line-height: 1.4;
outline: none;
white-space: pre-wrap;
overflow: auto;
}
/* 리스트 행간/여백 */
.note-content ul {
margin: 2px 0 4px 1.1em;
padding-left: 0.9em;
}
.note-content li {
margin: 0;
line-height: 1.35;
}
/* 링크 스타일 */
.note-content a {
color: #1d4ed8;
text-decoration: underline;
word-break: break-all;
}
/* 파스텔톤 색상 */
.note.yellow { background: #fff7c2; }
.note.yellow .note-header { background: #ffe28a; }
.note.pink { background: #ffdce7; }
.note.pink .note-header { background: #ffb6cf; }
.note.blue { background: #dcecff; }
.note.blue .note-header { background: #afc8ff; }
.note.green { background: #e2f7e9; }
.note.green .note-header { background: #bfe8cd; }
/* 크기 조절 핸들 */
.note-resize {
position: absolute;
right: 4px;
bottom: 4px;
width: 14px;
height: 14px;
cursor: se-resize;
background: rgba(0,0,0,0.18);
border-radius: 8px; /* ↔ note 라운딩과 맞춤 */
}
@media (max-width: 768px) {
body { overflow: auto; }
}
</style>
</head>
<body>
<div id="toolbar">
<button id="addNoteBtn">+ 새 메모</button>
<span id="status">불러오는 중...</span>
</div>
<script>
let notes = [];
let dragState = { id: null, offsetX: 0, offsetY: 0 };
let resizeState = { id: null, startX: 0, startY: 0, startW: 0, startH: 0 };
let maxZ = 1;
let saveTimer = null;
const statusEl = () => document.getElementById('status');
function setStatus(msg) {
if (!statusEl()) return;
statusEl().textContent = msg || '';
}
function scheduleSave() {
if (saveTimer) clearTimeout(saveTimer);
setStatus('저장 중...');
saveTimer = setTimeout(() => {
google.script.run
.withSuccessHandler(() => {
setStatus('저장 완료');
setTimeout(() => setStatus(''), 800);
})
.withFailureHandler(err => {
setStatus('저장 실패: ' + err.message);
})
.saveNotes(notes);
}, 400);
}
function getNote(id) {
return notes.find(n => n.id === id);
}
function bringToFront(note) {
maxZ += 1;
note.z = maxZ;
const el = document.querySelector(`.note[data-id="${note.id}"]`);
if (el) el.style.zIndex = note.z;
scheduleSave();
}
// 첫 줄을 제목으로
function updateTitleFromContent(note, contentEl, titleEl) {
if (!contentEl || !titleEl) return;
const text = contentEl.innerText || '';
const firstLine =
(text.split(/r?n/).find(line => line.trim() !== '') || '메모')
.trim();
titleEl.textContent = firstLine.length > 30
? firstLine.slice(0, 30) + '...'
: firstLine;
}
// URL 자동 링크
function autoLink(root) {
const urlRegex = /(https?://[^s]+)/g;
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
const targets = [];
let node;
while ((node = walker.nextNode())) {
if (!node.nodeValue || !urlRegex.test(node.nodeValue)) continue;
if (node.parentNode && node.parentNode.closest('a')) continue; // 이미 링크이면 패스
targets.push(node);
urlRegex.lastIndex = 0;
}
targets.forEach(textNode => {
const text = textNode.nodeValue;
let lastIndex = 0;
let match;
const frag = document.createDocumentFragment();
while ((match = urlRegex.exec(text))) {
const url = match[0];
if (match.index > lastIndex) {
frag.appendChild(document.createTextNode(text.slice(lastIndex, match.index)));
}
const a = document.createElement('a');
a.href = url;
a.textContent = url;
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.contentEditable = 'false'; // 링크는 클릭 가능하게, 편집은 불가
frag.appendChild(a);
lastIndex = match.index + url.length;
}
if (lastIndex < text.length) {
frag.appendChild(document.createTextNode(text.slice(lastIndex)));
}
textNode.parentNode.replaceChild(frag, textNode);
});
}
function createToolbar(content, note, titleEl) {
const toolbar = document.createElement('div');
toolbar.className = 'note-toolbar';
const buttons = [
{ cmd: 'bold', label: 'B' },
{ cmd: 'italic', label: 'I' },
{ cmd: 'underline', label: 'U' },
{ cmd: 'insertUnorderedList', label: '•' },
];
buttons.forEach(b => {
const btn = document.createElement('button');
btn.textContent = b.label;
btn.title = b.cmd;
btn.addEventListener('click', (e) => {
e.preventDefault();
content.focus();
document.execCommand(b.cmd, false, null);
const n = getNote(note.id);
if (!n) return;
autoLink(content);
n.text = content.innerHTML;
updateTitleFromContent(n, content, titleEl);
scheduleSave();
});
toolbar.appendChild(btn);
});
return toolbar;
}
function createNoteElement(note) {
const el = document.createElement('div');
el.className = 'note ' + (note.color || 'yellow');
el.style.left = (note.x || 80) + 'px';
el.style.top = (note.y || 80) + 'px';
el.style.width = (note.w || 260) + 'px';
el.style.height = (note.h || 160) + 'px';
el.style.zIndex = note.z || 1;
el.dataset.id = note.id;
const header = document.createElement('div');
header.className = 'note-header';
const title = document.createElement('div');
title.className = 'title';
title.textContent = '메모';
const actions = document.createElement('div');
actions.className = 'actions';
const colorSelect = document.createElement('select');
['yellow','pink','blue','green'].forEach(c => {
const opt = document.createElement('option');
opt.value = c;
opt.textContent = c;
if (c === note.color) opt.selected = true;
colorSelect.appendChild(opt);
});
const closeBtn = document.createElement('button');
closeBtn.innerHTML = '✕';
closeBtn.title = '삭제';
actions.appendChild(colorSelect);
actions.appendChild(closeBtn);
header.appendChild(title);
header.appendChild(actions);
const content = document.createElement('div');
content.className = 'note-content';
content.contentEditable = 'true';
content.spellcheck = false; // 빨간 물결 끄기
content.innerHTML = note.text || '';
autoLink(content); // 기존 내용도 링크 변환
const toolbar = createToolbar(content, note, title);
const resizeHandle = document.createElement('div');
resizeHandle.className = 'note-resize';
el.appendChild(header);
el.appendChild(toolbar);
el.appendChild(content);
el.appendChild(resizeHandle);
document.body.appendChild(el);
// 제목 초기화
updateTitleFromContent(note, content, title);
// 내용 입력 시 저장 + 링크 처리 + 제목 갱신
content.addEventListener('input', () => {
const n = getNote(note.id);
if (!n) return;
autoLink(content);
n.text = content.innerHTML;
updateTitleFromContent(n, content, title);
scheduleSave();
});
// 색상 변경
colorSelect.addEventListener('change', () => {
const n = getNote(note.id);
if (!n) return;
n.color = colorSelect.value;
el.classList.remove('yellow','pink','blue','green');
el.classList.add(n.color);
scheduleSave();
});
// 삭제
closeBtn.addEventListener('click', () => {
notes = notes.filter(n => n.id !== note.id);
el.remove();
scheduleSave();
});
// 드래그 시작
header.addEventListener('mousedown', (e) => {
const n = getNote(note.id);
if (!n) return;
dragState.id = note.id;
dragState.offsetX = e.clientX - (n.x || 0);
dragState.offsetY = e.clientY - (n.y || 0);
bringToFront(n);
});
// 클릭 시 맨 앞으로
el.addEventListener('mousedown', () => {
const n = getNote(note.id);
if (n) bringToFront(n);
});
// 크기 조절 시작
resizeHandle.addEventListener('mousedown', (e) => {
e.stopPropagation();
const n = getNote(note.id);
if (!n) return;
resizeState.id = note.id;
resizeState.startX = e.clientX;
resizeState.startY = e.clientY;
resizeState.startW = el.offsetWidth;
resizeState.startH = el.offsetHeight;
bringToFront(n);
});
}
function renderAll() {
document.querySelectorAll('.note').forEach(el => el.remove());
notes.forEach(n => createNoteElement(n));
}
document.addEventListener('mousemove', (e) => {
// 이동
if (dragState.id) {
const note = getNote(dragState.id);
if (!note) return;
note.x = e.clientX - dragState.offsetX;
note.y = e.clientY - dragState.offsetY;
const el = document.querySelector(`.note[data-id="${note.id}"]`);
if (el) {
el.style.left = note.x + 'px';
el.style.top = note.y + 'px';
}
scheduleSave();
}
// 크기 조절
if (resizeState.id) {
const note = getNote(resizeState.id);
if (!note) return;
const el = document.querySelector(`.note[data-id="${note.id}"]`);
if (!el) return;
const newW = Math.max(160, resizeState.startW + (e.clientX - resizeState.startX));
const newH = Math.max(140, resizeState.startH + (e.clientY - resizeState.startY));
note.w = newW;
note.h = newH;
el.style.width = newW + 'px';
el.style.height = newH + 'px';
scheduleSave();
}
});
document.addEventListener('mouseup', () => {
if (dragState.id || resizeState.id) {
scheduleSave();
}
dragState.id = null;
resizeState.id = null;
});
function init() {
setStatus('불러오는 중...');
google.script.run
.withSuccessHandler(serverNotes => {
if (Array.isArray(serverNotes) && serverNotes.length > 0) {
notes = serverNotes;
} else {
notes = [{
id: Date.now(),
x: 80,
y: 80,
w: 260,
h: 160,
text: '여기에 메모를 적어 보세요.',
color: 'yellow',
z: 1,
}];
}
maxZ = notes.reduce((m, n) => Math.max(m, n.z || 1), 1);
renderAll();
setStatus('');
})
.withFailureHandler(err => {
setStatus('불러오기 실패: ' + err.message);
})
.getNotes();
document.getElementById('addNoteBtn').addEventListener('click', () => {
const id = Date.now();
const newNote = {
id,
x: 100 + Math.random() * 80,
y: 100 + Math.random() * 80,
w: 260,
h: 160,
text: '',
color: 'yellow',
z: ++maxZ,
};
notes.push(newNote);
createNoteElement(newNote);
scheduleSave();
});
}
window.addEventListener('load', init);
</script>
</body>
</html>
🤖 사용한 AI 프롬프트
구글 시트와 앱스 스크립트를 사용해서 '스티커 메모 보드'를 만들고 싶어. 아래에 작성한 기본 기능을 참고해서 GS 코드와 HTML 코드를 만들어줘. * Google Apps Script 웹앱이 HTML(스티커 메모 보드)을 띄움 * 메모 정보는 Google Sheet에 저장/불러오기 * 메모를 움직이거나 수정할 때마다 자동으로 Sheet에 저장(백업) * 작성 시간, 수정 시간 기록 * 상단에 서식 툴바 추가(볼드, 기울기, 삭제, 밑줄, 글머리, 폰트 선택, 폰트 크기 선택) * 메모 오른쪽 아래 작은 크기 조절 핸들 추가, 드래그 → 폭·높이 변경 * 첫 줄을 자동으로 제목으로 지정, 자동으로 볼드가 되도록 지정(엔터로 제목을 구분해줘) * 메모지는 파스텔 톤 컬러로 지정하고, 기본 메모지는 노란색으로 해줘. * 모바일에서는 메모지가 한줄로 보이도록 해줘. * 기본 폰트 크기는 12로 해줘. 기본 폰트는 noto sans로 지정해줘. * 저장은 실시간으로 바로 반영되도록 해줘.