<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CHARACTER ARCHIVE</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
<style>
:root {
--bg: #f3f3f3;
--white: #ffffff;
--black: #262626;
--gray: #888888;
--line: #e5e5e5;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--black);
font-family: Arial, "Malgun Gothic", sans-serif;
}
button, input, textarea {
font-family: inherit;
}
button {
cursor: pointer;
}
header {
background: white;
border-bottom: 1px solid var(--line);
padding: 24px;
}
.header-inner {
max-width: 1150px;
margin: auto;
}
.logo {
font-size: 11px;
letter-spacing: 4px;
color: var(--gray);
}
header h1 {
font-size: 25px;
margin: 12px 0 6px;
}
header p {
font-size: 12px;
color: var(--gray);
margin: 0;
}
main {
max-width: 1150px;
margin: 28px auto;
padding: 0 18px;
display: grid;
grid-template-columns: minmax(0, 1fr) 350px;
gap: 25px;
align-items: start;
}
.panel {
background: white;
border: 1px solid var(--line);
padding: 23px;
min-width: 0;
}
.section-title {
font-size: 11px;
letter-spacing: 2px;
color: var(--gray);
margin: 26px 0 15px;
}
.section-title:first-child {
margin-top: 0;
}
label {
display: block;
font-size: 12px;
color: #666;
margin-bottom: 7px;
}
input, textarea {
width: 100%;
border: 1px solid #dedede;
background: #fafafa;
border-radius: 3px;
padding: 12px;
font-size: 13px;
outline: none;
margin-bottom: 15px;
color: var(--black);
}
input:focus, textarea:focus {
background: white;
border-color: #777;
}
textarea {
resize: vertical;
min-height: 75px;
line-height: 1.7;
}
.row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.upload {
display: block;
text-align: center;
padding: 17px;
border: 1px dashed #bbb;
cursor: pointer;
font-size: 12px;
color: #666;
}
.upload input {
display: none;
}
.photo-actions {
display: flex;
gap: 8px;
margin-top: 10px;
}
.photo-actions button {
flex: 1;
background: #eee;
border: none;
padding: 10px;
font-size: 11px;
}
.button {
border: none;
width: 100%;
padding: 14px;
font-size: 12px;
font-weight: bold;
margin-top: 9px;
}
.primary {
background: var(--black);
color: white;
}
.secondary {
background: #ededed;
color: #333;
}
.danger {
background: #f5eaea;
color: #9b3333;
}
.status {
font-size: 11px;
color: #888;
line-height: 1.7;
margin-top: 12px;
}
/* 저장된 캐릭터 목록 */
.library {
margin-top: 27px;
border-top: 1px solid var(--line);
padding-top: 20px;
}
.library-heading {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.library-heading h3 {
font-size: 13px;
margin: 0;
}
.count {
font-size: 11px;
color: #888;
}
.character-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.character-item {
display: flex;
align-items: center;
gap: 12px;
border: 1px solid #e5e5e5;
padding: 10px;
background: white;
}
.character-item.active {
border-color: #333;
background: #fafafa;
}
.item-open {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
flex: 1;
border: 0;
padding: 0;
background: transparent;
text-align: left;
}
.item-thumb {
width: 46px;
height: 53px;
flex-shrink: 0;
background: #eee;
object-fit: cover;
}
.item-name {
font-size: 12px;
font-weight: bold;
overflow-wrap: anywhere;
}
.item-sub {
font-size: 10px;
color: #999;
margin-top: 5px;
}
.item-delete {
border: 0;
background: transparent;
color: #999;
padding: 7px;
font-size: 11px;
}
.empty-list {
text-align: center;
padding: 25px 10px;
color: #aaa;
font-size: 12px;
line-height: 1.8;
}
/* 미리보기 */
.preview-area {
position: sticky;
top: 15px;
min-width: 0;
}
.preview-title {
text-align: center;
font-size: 10px;
color: #888;
letter-spacing: 3px;
margin-bottom: 13px;
}
#profile {
width: 100%;
background: white;
border: 1px solid #e5e5e5;
overflow: hidden;
box-shadow: 0 8px 30px #00000008;
}
.cover {
height: 9px;
background: #292929;
}
.photo-box {
height: 270px;
position: relative;
background: #ededed;
overflow: hidden;
}
.photo-box img {
display: none;
width: 100%;
height: 100%;
object-fit: cover;
}
.photo-placeholder {
position: absolute;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
color: #999;
}
.photo-placeholder strong {
font-size: 32px;
font-weight: normal;
}
.photo-placeholder span {
font-size: 9px;
letter-spacing: 3px;
margin-top: 8px;
}
.profile-content {
padding: 23px;
}
.eyebrow {
font-size: 9px;
letter-spacing: 3px;
color: #999;
margin-bottom: 12px;
}
#outName {
font-size: 28px;
line-height: 1.35;
overflow-wrap: anywhere;
margin: 0;
}
#outSub {
font-size: 11px;
color: #888;
margin-top: 7px;
overflow-wrap: anywhere;
}
.divider {
border: none;
border-top: 1px solid #e8e8e8;
margin: 20px 0;
}
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px 12px;
}
.info-label {
font-size: 9px;
letter-spacing: 1px;
color: #999;
margin-bottom: 6px;
}
.info-value {
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
overflow-wrap: anywhere;
min-height: 15px;
}
.detail {
margin-top: 23px;
}
.detail-title {
font-size: 10px;
letter-spacing: 2px;
color: #888;
margin-bottom: 9px;
}
.detail-text {
font-size: 11px;
line-height: 1.9;
color: #444;
white-space: pre-wrap;
overflow-wrap: anywhere;
min-height: 13px;
}
.card-footer {
border-top: 1px solid #eee;
padding-top: 13px;
margin-top: 25px;
display: flex;
justify-content: space-between;
gap: 5px;
font-size: 8px;
letter-spacing: 1px;
color: #aaa;
}
@media (max-width: 760px) {
main {
grid-template-columns: minmax(0, 1fr);
margin-top: 18px;
padding: 0 12px;
}
.preview-area {
position: static;
grid-row: 1;
width: 100%;
max-width: 350px;
margin: 0 auto;
}
.panel {
padding: 18px;
}
.photo-box {
height: 290px;
}
}
</style>
</head>
<body>
<header>
<div class="header-inner">
<div class="logo">C / A — CHARACTER ARCHIVE</div>
<h1>캐릭터 프로필 제작실</h1>
<p>당신의 캐릭터를 한 장의 기록으로.</p>
</div>
</header>
<main>
<!-- 편집 영역 -->
<section class="panel">
<div class="section-title">01 — CHARACTER IMAGE</div>
<label class="upload">
+ 캐릭터 사진 업로드
<input type="file" id="photoInput" accept="image/*">
</label>
<div class="photo-actions">
<button type="button" id="removePhoto">사진 제거</button>
</div>
<div class="section-title">02 — BASIC INFORMATION</div>
<label for="name">이름 NAME</label>
<input id="name" placeholder="캐릭터 이름">
<div class="row">
<div>
<label for="age">나이 AGE</label>
<input id="age" placeholder="예: 24세">
</div>
<div>
<label for="gender">성별 GENDER</label>
<input id="gender" placeholder="성별">
</div>
</div>
<div class="row">
<div>
<label for="height">키 HEIGHT</label>
<input id="height" placeholder="예: 172cm">
</div>
<div>
<label for="job">직업 JOB</label>
<input id="job" placeholder="직업">
</div>
</div>
<label for="affiliation">소속 AFFILIATION</label>
<input id="affiliation" placeholder="회사, 팀, 조직 등">
<div class="section-title">03 — CHARACTER DETAILS</div>
<label for="personality">성격 PERSONALITY</label>
<textarea id="personality" placeholder="성격, 말투, 행동 방식"></textarea>
<label for="feature">특징 FEATURES</label>
<textarea id="feature" placeholder="외모, 능력, 습관, 특징"></textarea>
<label for="likes">좋아하는 것 LIKE</label>
<textarea id="likes" placeholder="좋아하는 음식, 취미, 인물 등"></textarea>
<label for="dislikes">싫어하는 것 DISLIKE</label>
<textarea id="dislikes" placeholder="싫어하는 것"></textarea>
<label for="story">과거 및 서사 BACKGROUND</label>
<textarea id="story" placeholder="캐릭터의 과거와 서사"></textarea>
<label for="etc">기타 설정 OTHER</label>
<textarea id="etc" placeholder="그 밖의 설정"></textarea>
<button class="button primary" id="saveCharacter">
캐릭터 저장 / 수정
</button>
<button class="button secondary" id="newCharacter">
+ 새 캐릭터 작성
</button>
<button class="button secondary" id="exportImage">
↓ 프로필 PNG 이미지 저장
</button>
<button class="button danger" id="deleteCurrent">
현재 캐릭터 삭제
</button>
<div class="status" id="status">
아직 저장하지 않은 캐릭터입니다.<br>
'캐릭터 저장 / 수정'을 눌러 저장하세요.
</div>
<!-- 저장된 캐릭터 -->
<div class="library">
<div class="library-heading">
<h3>CHARACTER LIBRARY</h3>
<span class="count" id="count">0 CHARACTERS</span>
</div>
<div class="character-list" id="characterList"></div>
</div>
</section>
<!-- 프로필 미리보기 -->
<section class="preview-area">
<div class="preview-title">LIVE PREVIEW</div>
<div id="profile">
<div class="cover"></div>
<div class="photo-box">
<img id="outPhoto" alt="캐릭터 사진">
<div class="photo-placeholder" id="placeholder">
<strong>+</strong>
<span>CHARACTER IMAGE</span>
</div>
</div>
<div class="profile-content">
<div class="eyebrow">CHARACTER FILE</div>
<h2 id="outName">이름을 입력하세요</h2>
<div id="outSub">CHARACTER PROFILE</div>
<hr class="divider">
<div class="info-grid">
<div>
<div class="info-label">AGE</div>
<div class="info-value" id="outAge">—</div>
</div>
<div>
<div class="info-label">GENDER</div>
<div class="info-value" id="outGender">—</div>
</div>
<div>
<div class="info-label">HEIGHT</div>
<div class="info-value" id="outHeight">—</div>
</div>
<div>
<div class="info-label">OCCUPATION</div>
<div class="info-value" id="outJob">—</div>
</div>
<div style="grid-column:1/-1">
<div class="info-label">AFFILIATION</div>
<div class="info-value" id="outAffiliation">—</div>
</div>
</div>
<div class="detail">
<div class="detail-title">PERSONALITY</div>
<div class="detail-text" id="outPersonality">—</div>
</div>
<div class="detail">
<div class="detail-title">FEATURES</div>
<div class="detail-text" id="outFeature">—</div>
</div>
<div class="detail">
<div class="detail-title">LIKE</div>
<div class="detail-text" id="outLikes">—</div>
</div>
<div class="detail">
<div class="detail-title">DISLIKE</div>
<div class="detail-text" id="outDislikes">—</div>
</div>
<div class="detail">
<div class="detail-title">BACKGROUND</div>
<div class="detail-text" id="outStory">—</div>
</div>
<div class="detail">
<div class="detail-title">OTHER INFORMATION</div>
<div class="detail-text" id="outEtc">—</div>
</div>
<div class="card-footer">
<span>CHARACTER ARCHIVE</span>
<span>CONFIDENTIAL</span>
</div>
</div>
</div>
</section>
</main>
<script>
const STORAGE_KEY = "character_archive_v2";
const fieldIds = [
"name",
"age",
"gender",
"height",
"job",
"affiliation",
"personality",
"feature",
"likes",
"dislikes",
"story",
"etc"
];
const outputIds = {
name: "outName",
age: "outAge",
gender: "outGender",
height: "outHeight",
job: "outJob",
affiliation: "outAffiliation",
personality: "outPersonality",
feature: "outFeature",
likes: "outLikes",
dislikes: "outDislikes",
story: "outStory",
etc: "outEtc"
};
const defaults = {
outName: "이름을 입력하세요",
outAge: "—",
outGender: "—",
outHeight: "—",
outJob: "—",
outAffiliation: "—",
outPersonality: "—",
outFeature: "—",
outLikes: "—",
outDislikes: "—",
outStory: "—",
outEtc: "—"
};
let characters = [];
let currentId = null;
let currentPhoto = "";
let busy = false;
const $ = id => document.getElementById(id);
// 브라우저 저장소 불러오기
function loadLibrary() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : [];
characters = Array.isArray(parsed)
? parsed.filter(c =>
c &&
typeof c.id === "string" &&
c.values &&
typeof c.values === "object"
)
: [];
} catch (error) {
characters = [];
alert("저장된 목록을 읽지 못했어요.");
}
renderLibrary();
}
// 브라우저에 저장하기
function persistLibrary() {
try {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify(characters)
);
return true;
} catch (error) {
alert(
"저장 공간이 부족하거나 브라우저 저장이 제한되어 있어요.\n" +
"사진 용량을 줄이거나 필요 없는 캐릭터를 삭제해 주세요."
);
return false;
}
}
// 현재 입력값 모으기
function readForm() {
const values = {};
fieldIds.forEach(id => {
values[id] = $(id).value;
});
return values;
}
// 입력값을 화면에 표시
function renderProfile(values, photo) {
fieldIds.forEach(id => {
const output = $(outputIds[id]);
const value = (values[id] || "").trim();
output.textContent = value || defaults[output.id];
});
const name = (values.name || "").trim();
$("outSub").textContent = name
? "CHARACTER PROFILE"
: "CHARACTER PROFILE";
currentPhoto = photo || "";
const img = $("outPhoto");
if (currentPhoto) {
img.onload = () => {
img.style.display = "block";
$("placeholder").style.display = "none";
};
img.onerror = () => {
img.style.display = "none";
$("placeholder").style.display = "flex";
};
img.src = currentPhoto;
if (img.complete && img.naturalWidth > 0) {
img.style.display = "block";
$("placeholder").style.display = "none";
}
} else {
img.removeAttribute("src");
img.style.display = "none";
$("placeholder").style.display = "flex";
}
}
// 입력 폼과 미리보기 동기화
function syncPreview() {
renderProfile(readForm(), currentPhoto);
}
fieldIds.forEach(id => {
$(id).addEventListener("input", syncPreview);
});
// 사진 용량 줄이기
async function compressImage(file) {
const image = new Image();
const url = URL.createObjectURL(file);
try {
await new Promise((resolve, reject) => {
image.onload = resolve;
image.onerror = reject;
image.src = url;
});
const maxSize = 1200;
const ratio = Math.min(
1,
maxSize / Math.max(image.naturalWidth, image.naturalHeight)
);
const canvas = document.createElement("canvas");
canvas.width = Math.max(
1,
Math.round(image.naturalWidth * ratio)
);
canvas.height = Math.max(
1,
Math.round(image.naturalHeight * ratio)
);
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(
image,
0,
0,
canvas.width,
canvas.height
);
return canvas.toDataURL("image/jpeg", 0.85);
} finally {
URL.revokeObjectURL(url);
}
}
// 사진 업로드
$("photoInput").addEventListener("change", async event => {
const file = event.target.files[0];
if (!file) return;
if (!file.type.startsWith("image/")) {
alert("이미지 파일을 선택해 주세요.");
event.target.value = "";
return;
}
try {
currentPhoto = await compressImage(file);
syncPreview();
$("status").textContent =
"사진을 불러왔어요. 저장 버튼을 누르면 캐릭터에 반영됩니다.";
} catch (error) {
alert("사진을 불러오지 못했어요.");
}
event.target.value = "";
});
// 사진 제거
$("removePhoto").addEventListener("click", () => {
currentPhoto = "";
syncPreview();
$("status").textContent =
"사진을 제거했어요. 저장 버튼을 눌러 변경 사항을 보존하세요.";
});
// 새 캐릭터 작성
function newCharacter() {
currentId = null;
currentPhoto = "";
fieldIds.forEach(id => {
$(id).value = "";
});
$("photoInput").value = "";
syncPreview();
$("status").textContent =
"새 캐릭터 작성 중입니다. 기존 캐릭터는 삭제되지 않아요.";
$("saveCharacter").textContent = "캐릭터 저장";
$("deleteCurrent").disabled = true;
$("deleteCurrent").style.opacity = "0.45";
renderLibrary();
}
$("newCharacter").addEventListener("click", newCharacter);
// 캐릭터 저장 또는 수정
$("saveCharacter").addEventListener("click", () => {
if (busy) return;
const values = readForm();
if (!values.name.trim()) {
alert("캐릭터 이름을 입력해 주세요!");
$("name").focus();
return;
}
const existingIndex = characters.findIndex(
c => c.id === currentId
);
const character = {
id: currentId || (
typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: Date.now().toString(36) +
Math.random().toString(36).slice(2)
),
values: values,
photo: currentPhoto,
updatedAt: new Date().toISOString()
};
const previous = existingIndex >= 0
? characters[existingIndex]
: null;
if (existingIndex >= 0) {
characters[existingIndex] = character;
} else {
characters.unshift(character);
}
if (!persistLibrary()) {
if (previous) {
characters[existingIndex] = previous;
} else {
characters = characters.filter(
c => c.id !== character.id
);
}
return;
}
currentId = character.id;
$("status").textContent =
"저장 완료! 이 캐릭터는 브라우저에 보관됩니다.";
$("saveCharacter").textContent = "캐릭터 수정 저장";
$("deleteCurrent").disabled = false;
$("deleteCurrent").style.opacity = "1";
renderLibrary();
});
// 캐릭터 목록 그리기
function renderLibrary() {
const list = $("characterList");
list.replaceChildren();
$("count").textContent =
characters.length + " CHARACTERS";
if (characters.length === 0) {
const empty = document.createElement("div");
empty.className = "empty-list";
empty.textContent =
"아직 저장된 캐릭터가 없습니다. " +
"첫 번째 캐릭터를 만들어 보세요.";
list.appendChild(empty);
return;
}
characters.forEach(character => {
const item = document.createElement("div");
item.className =
"character-item" +
(character.id === currentId ? " active" : "");
const open = document.createElement("button");
open.type = "button";
open.className = "item-open";
const thumb = document.createElement("img");
thumb.className = "item-thumb";
thumb.alt = "";
if (character.photo) {
thumb.src = character.photo;
}
const textBox = document.createElement("div");
textBox.style.minWidth = "0";
const name = document.createElement("div");
name.className = "item-name";
name.textContent =
(character.values.name || "").trim() || "이름 없음";
const sub = document.createElement("div");
sub.className = "item-sub";
sub.textContent = [
character.values.age,
character.values.job
].filter(Boolean).join(" · ") || "캐릭터 프로필";
textBox.append(name, sub);
open.append(thumb, textBox);
open.addEventListener("click", () => {
openCharacter(character.id);
});
const del = document.createElement("button");
del.type = "button";
del.className = "item-delete";
del.textContent = "삭제";
del.addEventListener("click", () => {
deleteCharacter(character.id);
});
item.append(open, del);
list.appendChild(item);
});
}
// 저장된 캐릭터 불러오기
function openCharacter(id) {
const character = characters.find(c => c.id === id);
if (!character) return;
currentId = character.id;
currentPhoto = character.photo || "";
fieldIds.forEach(field => {
$(field).value = character.values[field] || "";
});
$("saveCharacter").textContent = "캐릭터 수정 저장";
$("deleteCurrent").disabled = false;
$("deleteCurrent").style.opacity = "1";
syncPreview();
$("status").textContent =
"저장된 캐릭터를 불러왔어요. 수정 후 저장하면 변경됩니다.";
renderLibrary();
// 모바일에서 편집 영역으로 이동
$("name").scrollIntoView({
behavior: "smooth",
block: "center"
});
}
// 캐릭터 삭제
function deleteCharacter(id) {
const character = characters.find(c => c.id === id);
if (!character) return;
const name = character.values.name || "이 캐릭터";
if (!confirm(
'"' + name + '" 캐릭터를 삭제할까요?\n' +
"삭제한 캐릭터는 복구할 수 없어요."
)) {
return;
}
const oldCharacters = [...characters];
characters = characters.filter(c => c.id !== id);
if (!persistLibrary()) {
characters = oldCharacters;
renderLibrary();
return;
}
if (currentId === id) {
newCharacter();
} else {
renderLibrary();
}
$("status").textContent = "캐릭터를 삭제했어요.";
}
$("deleteCurrent").addEventListener("click", () => {
if (currentId) {
deleteCharacter(currentId);
}
});
// PNG 이미지 저장
$("exportImage").addEventListener("click", async () => {
if (busy) return;
if (typeof html2canvas === "undefined") {
alert(
"이미지 저장 기능을 불러오지 못했어요. " +
"인터넷 연결을 확인해 주세요."
);
return;
}
busy = true;
const button = $("exportImage");
button.textContent = "이미지 생성 중...";
button.disabled = true;
try {
const photo = $("outPhoto");
if (photo.style.display !== "none" && photo.src) {
await photo.decode().catch(() => {});
}
const canvas = await html2canvas($("profile"), {
scale: 2,
backgroundColor: "#ffffff",
logging: false
});
const link = document.createElement("a");
const name =
$("name").value.trim() || "character";
link.download =
name.replace(/[\\/:*?"<>|]/g, "_") +
"_profile.png";
link.href = canvas.toDataURL("image/png");
link.click();
$("status").textContent =
"프로필 이미지를 만들었어요!";
} catch (error) {
console.error(error);
alert(
"이미지 저장에 실패했어요. " +
"프로필에 너무 긴 글이 있는지 확인하고 다시 시도해 주세요."
);
} finally {
busy = false;
button.textContent = "↓ 프로필 PNG 이미지 저장";
button.disabled = false;
}
});
// 처음 실행
loadLibrary();
newCharacter();
</script>
</body>
</html>