first commit
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
:root {
|
||||
--ink: #20221d;
|
||||
--paper: #f5f1e8;
|
||||
--paper-deep: #e8e0d1;
|
||||
--green: #3f5b45;
|
||||
--green-dark: #263d2c;
|
||||
--orange: #d7693d;
|
||||
--line: rgba(32, 34, 29, 0.18);
|
||||
--muted: #77786f;
|
||||
--white: #fffdf8;
|
||||
--serif: Georgia, 'Times New Roman', serif;
|
||||
--sans: 'Noto Sans KR', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html { scroll-behavior: smooth; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
background: var(--paper);
|
||||
font-family: var(--sans);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
button, input, textarea { font: inherit; }
|
||||
button, a { -webkit-tap-highlight-color: transparent; }
|
||||
a { color: inherit; }
|
||||
img { display: block; max-width: 100%; }
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.site-header {
|
||||
position: sticky;
|
||||
z-index: 20;
|
||||
top: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 76px;
|
||||
padding: 12px clamp(20px, 5vw, 72px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(245, 241, 232, 0.93);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-family: var(--serif);
|
||||
font-size: 1.25rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: grid;
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
color: var(--paper);
|
||||
background: var(--green);
|
||||
font-family: var(--serif);
|
||||
font-size: 1.2rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.brand-mark-small { width: 44px; height: 44px; font-size: 0.75rem; }
|
||||
|
||||
.profile { display: flex; align-items: center; gap: 12px; font-size: 0.86rem; }
|
||||
.avatar { width: 32px; height: 32px; border-radius: 50%; object-fit: cover; }
|
||||
.menu-button { display: grid; width: 42px; height: 42px; padding: 10px; place-content: center; gap: 4px; border: 1px solid var(--line); border-radius: 50%; cursor: pointer; background: transparent; }
|
||||
.menu-button[hidden] { display: none; }
|
||||
.menu-button span { display: block; width: 18px; height: 2px; border-radius: 2px; background: var(--ink); }
|
||||
.menu-button:hover { background: var(--paper-deep); }
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
padding: 10px 18px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: transform 150ms ease, background 150ms ease, opacity 150ms ease;
|
||||
}
|
||||
|
||||
.button:hover { transform: translateY(-1px); }
|
||||
.button:disabled { cursor: wait; opacity: 0.55; transform: none; }
|
||||
.button-primary { color: var(--white); background: var(--green-dark); }
|
||||
.button-primary:hover { background: var(--green); }
|
||||
.button-secondary { border-color: var(--ink); background: transparent; }
|
||||
.button-quiet { min-height: 38px; padding: 7px 14px; border-color: var(--line); background: transparent; }
|
||||
.button-danger { border-color: #a43928; color: #8d3022; background: transparent; }
|
||||
.google-login { width: 100%; gap: 12px; }
|
||||
.google-login span { display: grid; width: 24px; height: 24px; place-items: center; border-radius: 50%; color: var(--green-dark); background: white; }
|
||||
|
||||
.page-shell { width: min(1240px, calc(100% - 40px)); margin: 0 auto; padding-bottom: 100px; }
|
||||
|
||||
.hero { padding: clamp(70px, 10vw, 140px) 0 clamp(60px, 9vw, 120px); }
|
||||
.hero h1 {
|
||||
margin: 10px 0 24px;
|
||||
font-family: var(--serif);
|
||||
font-size: clamp(3rem, 7vw, 6.8rem);
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.035em;
|
||||
line-height: 0.98;
|
||||
}
|
||||
.hero h1 span { display: block; white-space: nowrap; }
|
||||
.hero > p:last-child { max-width: 630px; margin: 0; color: var(--muted); font-size: 1.05rem; }
|
||||
.eyebrow, .section-number { margin: 0; color: var(--orange); font-size: 0.72rem; font-weight: 700; letter-spacing: 0.16em; }
|
||||
|
||||
.import-panel, .preview-panel {
|
||||
padding: clamp(24px, 4vw, 52px);
|
||||
border-radius: 28px;
|
||||
background: var(--green);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.import-panel { display: grid; grid-template-columns: minmax(180px, 0.7fr) 2fr; gap: 30px 54px; align-items: center; }
|
||||
.import-panel h2, .preview-panel h2, .library h2 { margin: 4px 0 0; font-family: var(--serif); font-size: clamp(2rem, 4vw, 3.4rem); font-weight: 400; }
|
||||
.import-form { display: flex; gap: 10px; padding: 8px; border-radius: 999px; background: var(--white); }
|
||||
.import-form input { flex: 1; min-width: 0; padding: 10px 18px; border: 0; outline: 0; color: var(--ink); background: transparent; }
|
||||
.status { grid-column: 2; padding: 12px 18px; border-radius: 12px; background: rgba(255, 255, 255, 0.1); }
|
||||
.status.success { color: #d8efc9; }
|
||||
.status.error { color: #ffd0c4; }
|
||||
|
||||
.preview-panel { margin-top: 28px; color: var(--ink); background: var(--white); border: 1px solid var(--line); }
|
||||
.section-heading, .editor-section-heading, .editor-group-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
|
||||
.preview-layout { display: grid; grid-template-columns: minmax(240px, 0.8fr) 1.2fr; gap: 38px; margin-top: 34px; }
|
||||
.preview-image, .detail-image, .recipe-card-image {
|
||||
position: relative;
|
||||
display: grid;
|
||||
overflow: hidden;
|
||||
place-items: center;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
background: var(--green-dark);
|
||||
}
|
||||
.preview-image { min-height: 330px; border-radius: 20px; }
|
||||
.preview-image img, .detail-image img, .recipe-card-image img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.preview-image .platform-label { position: absolute; bottom: 16px; left: 16px; }
|
||||
.platform-label { display: inline-flex; padding: 5px 10px; border-radius: 999px; color: var(--white); background: var(--orange); font-size: 0.67rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
|
||||
|
||||
.editor-fields { display: grid; gap: 18px; }
|
||||
.editor-fields.two-column { grid-template-columns: 1fr 1fr; margin-top: 28px; }
|
||||
.editor-fields label { display: grid; gap: 7px; color: var(--muted); font-size: 0.78rem; font-weight: 700; }
|
||||
.editor-fields input, .editor-fields textarea, .editor-group input, .ingredient-row input, .step-row textarea, .step-row input {
|
||||
width: 100%;
|
||||
padding: 12px 13px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
outline: none;
|
||||
color: var(--ink);
|
||||
background: #fff;
|
||||
}
|
||||
.editor-fields input:focus, .editor-fields textarea:focus, .editor-group input:focus, .ingredient-row input:focus, .step-row textarea:focus, .step-row input:focus { border-color: var(--green); box-shadow: 0 0 0 3px rgba(63, 91, 69, 0.12); }
|
||||
.editor-fields #recipe-title { font-family: var(--serif); font-size: 2rem; }
|
||||
.editor-section { margin-top: 36px; padding-top: 30px; border-top: 1px solid var(--line); }
|
||||
.editor-section h3 { margin: 0; font-family: var(--serif); font-size: 1.7rem; font-weight: 400; }
|
||||
.editor-group { margin: 18px 0 0; padding: 18px; border: 1px solid var(--line); border-radius: 14px; }
|
||||
.editor-group-heading > input { max-width: 300px; font-weight: 700; }
|
||||
.ingredient-list { display: grid; gap: 8px; margin: 14px 0; }
|
||||
.ingredient-row { display: grid; grid-template-columns: 1fr 0.75fr 38px; gap: 8px; }
|
||||
.text-button, .icon-button { border: 0; cursor: pointer; color: var(--green); background: transparent; font-weight: 700; }
|
||||
.text-button.danger { color: #9a3c2c; }
|
||||
.icon-button { font-size: 1.25rem; }
|
||||
.step-row { display: grid; grid-template-columns: 34px 1fr 90px 34px; gap: 10px; align-items: start; margin-top: 12px; }
|
||||
.step-row > span { display: grid; width: 32px; height: 32px; place-items: center; border-radius: 50%; color: var(--white); background: var(--green); font-size: 0.75rem; }
|
||||
.form-actions { display: flex; justify-content: flex-end; margin-top: 30px; }
|
||||
|
||||
.library { padding-top: clamp(70px, 10vw, 120px); }
|
||||
.count-label { color: var(--muted); font-size: 0.72rem; letter-spacing: 0.12em; }
|
||||
.recipe-search { display: flex; max-width: 660px; gap: 10px; margin-top: 30px; padding: 8px 14px 8px 20px; border: 1px solid var(--line); border-radius: 999px; background: var(--white); }
|
||||
.recipe-search:focus-within { border-color: var(--green); box-shadow: 0 0 0 3px rgba(63, 91, 69, 0.12); }
|
||||
.recipe-search input { flex: 1; min-width: 0; padding: 6px 0; border: 0; outline: 0; color: var(--ink); background: transparent; }
|
||||
.tag-filter { margin-top: 30px; padding: 18px 20px; border: 1px solid var(--line); border-radius: 16px; background: rgba(255, 253, 248, 0.65); }
|
||||
.tag-filter-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; font-size: 0.82rem; font-weight: 700; }
|
||||
.tag-filter-heading small { margin-left: 8px; color: var(--muted); font-weight: 400; }
|
||||
.tag-filter-actions { display: flex; align-items: center; gap: 12px; }
|
||||
.tag-filter-options { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 13px; }
|
||||
.tag-filter-button { padding: 7px 12px; border: 1px solid var(--line); border-radius: 999px; cursor: pointer; color: var(--green); background: var(--white); font-size: 0.78rem; }
|
||||
.tag-filter-button[aria-pressed="true"] { border-color: var(--green); color: var(--white); background: var(--green); }
|
||||
.recipe-results { min-height: 430px; }
|
||||
.recipe-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; margin-top: 38px; }
|
||||
.recipe-card { overflow: hidden; border: 1px solid var(--line); border-radius: 20px; background: var(--white); text-decoration: none; transition: transform 180ms ease, box-shadow 180ms ease; }
|
||||
.recipe-card:hover { transform: translateY(-4px); box-shadow: 0 18px 50px rgba(38, 61, 44, 0.12); }
|
||||
.recipe-card-image { aspect-ratio: 4 / 3; }
|
||||
.recipe-card-body { padding: 22px; }
|
||||
.recipe-card-body h3 { margin: 13px 0 7px; font-family: var(--serif); font-size: 1.65rem; font-weight: 400; line-height: 1.12; }
|
||||
.recipe-card-body p { margin: 0; color: var(--muted); font-size: 0.85rem; }
|
||||
.recipe-card-body .recipe-card-match { margin-top: 12px; color: var(--orange); font-weight: 700; }
|
||||
.recipe-card-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 14px; }
|
||||
.recipe-card-tags .tag { margin: 0; }
|
||||
.empty-state { padding: 50px; text-align: center; color: var(--muted); }
|
||||
|
||||
.drawer-open { overflow: hidden; }
|
||||
.drawer-backdrop { position: fixed; z-index: 39; inset: 0; border: 0; cursor: default; background: rgba(32, 34, 29, 0.42); }
|
||||
.access-drawer { position: fixed; z-index: 40; top: 0; right: 0; width: min(440px, 100%); height: 100dvh; overflow-y: auto; padding: 34px; border-left: 1px solid var(--line); background: var(--white); box-shadow: -20px 0 70px rgba(32, 34, 29, 0.2); }
|
||||
.drawer-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; }
|
||||
.drawer-heading h2 { margin: 4px 0 0; font-family: var(--serif); font-size: 2.4rem; font-weight: 400; line-height: 1.05; }
|
||||
.drawer-close { display: grid; width: 42px; height: 42px; flex: 0 0 auto; padding: 0; place-items: center; border: 1px solid var(--line); border-radius: 50%; cursor: pointer; color: var(--ink); background: transparent; font-size: 1.8rem; line-height: 1; }
|
||||
.drawer-close:hover { background: var(--paper); }
|
||||
.drawer-account { display: grid; gap: 2px; margin-top: 34px; padding: 18px; border-radius: 14px; background: var(--paper); }
|
||||
.drawer-account strong { font-family: var(--serif); font-size: 1.25rem; font-weight: 400; }
|
||||
.drawer-account span { overflow-wrap: anywhere; color: var(--muted); font-size: 0.78rem; }
|
||||
.drawer-actions { display: grid; gap: 10px; margin-top: 28px; }
|
||||
.drawer-action { display: flex; align-items: center; justify-content: space-between; width: 100%; min-height: 52px; padding: 12px 16px; border: 1px solid var(--line); border-radius: 12px; cursor: pointer; color: var(--ink); background: transparent; font-weight: 700; text-align: left; }
|
||||
.drawer-action:hover { background: var(--paper); }
|
||||
.drawer-action small { color: var(--orange); font-size: 0.68rem; }
|
||||
.drawer-action.danger { color: #8d3022; }
|
||||
.drawer-action[hidden] { display: none; }
|
||||
.access-overlay-backdrop { position: fixed; z-index: 59; inset: 0; border: 0; cursor: default; background: rgba(32, 34, 29, 0.55); }
|
||||
.access-overlay { position: fixed; z-index: 60; top: 50%; left: 50%; width: min(560px, calc(100% - 40px)); max-height: min(720px, calc(100dvh - 40px)); overflow-y: auto; padding: 34px; border: 1px solid var(--line); border-radius: 24px; background: var(--white); box-shadow: 0 28px 90px rgba(32, 34, 29, 0.28); transform: translate(-50%, -50%); }
|
||||
.access-copy { margin: 14px 0 20px; color: var(--muted); }
|
||||
.access-form { display: grid; gap: 10px; }
|
||||
.access-form input { width: 100%; min-width: 0; padding: 12px 16px; border: 1px solid var(--line); border-radius: 12px; outline: none; }
|
||||
.access-form input:focus { border-color: var(--green); box-shadow: 0 0 0 3px rgba(63, 91, 69, 0.12); }
|
||||
.allowed-email-list { display: grid; gap: 8px; margin-top: 22px; }
|
||||
.allowed-email-row { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 12px 16px; border-radius: 12px; background: var(--paper); }
|
||||
.allowed-email-row small { color: var(--muted); }
|
||||
|
||||
.login-page { min-height: 100vh; display: grid; place-items: center; padding: 24px; background: radial-gradient(circle at 75% 20%, #e5b79e 0, transparent 28%), var(--paper); }
|
||||
.login-card { width: min(440px, 100%); padding: clamp(34px, 7vw, 62px); border: 1px solid var(--line); border-radius: 30px; background: rgba(255, 253, 248, 0.88); box-shadow: 0 30px 100px rgba(38, 61, 44, 0.14); text-align: center; backdrop-filter: blur(10px); }
|
||||
.login-card .brand-mark { margin: 0 auto 28px; }
|
||||
.login-card h1 { margin: 8px 0 16px; font-family: var(--serif); font-size: 3rem; font-weight: 400; line-height: 1; }
|
||||
.login-copy { margin: 0 0 32px; color: var(--muted); }
|
||||
.fine-print { margin: 18px 0 0; color: var(--muted); font-size: 0.72rem; }
|
||||
|
||||
.detail-shell { width: min(1160px, calc(100% - 40px)); margin: 0 auto; padding: 50px 0 100px; }
|
||||
.back-link { display: inline-block; margin-bottom: 28px; color: var(--muted); text-decoration: none; }
|
||||
.recipe-detail { overflow: hidden; border: 1px solid var(--line); border-radius: 28px; background: var(--white); }
|
||||
.detail-hero { display: grid; grid-template-columns: 1.05fr 0.95fr; min-height: 480px; }
|
||||
.detail-image { min-height: 420px; }
|
||||
.detail-intro { display: flex; flex-direction: column; align-items: flex-start; justify-content: center; padding: clamp(34px, 6vw, 74px); }
|
||||
.detail-intro h1 { margin: 18px 0; font-family: var(--serif); font-size: clamp(2.7rem, 5vw, 5rem); font-weight: 400; line-height: 0.98; }
|
||||
.detail-intro > p { color: var(--muted); }
|
||||
.servings { margin: 12px 0 28px; color: var(--ink) !important; font-size: 0.82rem; font-weight: 700; }
|
||||
.detail-columns { display: grid; grid-template-columns: 0.8fr 1.2fr; gap: clamp(36px, 7vw, 90px); padding: clamp(38px, 7vw, 86px); border-top: 1px solid var(--line); }
|
||||
.detail-columns h2 { margin: 5px 0 28px; font-family: var(--serif); font-size: 2.5rem; font-weight: 400; }
|
||||
.ingredient-detail { margin-bottom: 28px; }
|
||||
.ingredient-detail h3 { margin: 0 0 10px; font-size: 0.86rem; }
|
||||
.ingredient-detail ul, .tips ul { margin: 0; padding: 0; list-style: none; }
|
||||
.ingredient-detail li { display: flex; justify-content: space-between; gap: 20px; padding: 9px 0; border-bottom: 1px dashed var(--line); font-size: 0.9rem; }
|
||||
.ingredient-detail li strong { font-weight: 500; color: var(--muted); }
|
||||
.method-list { display: grid; gap: 22px; margin: 0; padding: 0; list-style: none; }
|
||||
.method-list li { display: grid; grid-template-columns: 46px 1fr; gap: 18px; align-items: start; }
|
||||
.method-list li > strong { color: var(--orange); font-family: var(--serif); font-size: 1.6rem; font-weight: 400; }
|
||||
.method-list a { display: grid; color: inherit; text-decoration: none; }
|
||||
.method-list small { margin-top: 4px; color: var(--orange); }
|
||||
.tips { margin-top: 42px; padding: 24px; border-radius: 16px; background: var(--paper); }
|
||||
.tips h3 { margin-top: 0; font-family: var(--serif); font-size: 1.5rem; font-weight: 400; }
|
||||
.tips li { margin-top: 8px; }
|
||||
.detail-footer { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 24px clamp(38px, 7vw, 86px); border-top: 1px solid var(--line); }
|
||||
.detail-actions { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.recipe-edit-panel { color: var(--ink); background: var(--white); }
|
||||
.tag { margin-right: 12px; color: var(--green); font-size: 0.78rem; }
|
||||
.scroll-to-top { position: fixed; z-index: 30; right: 24px; bottom: 24px; display: grid; width: 52px; height: 52px; padding: 0; place-items: center; border: 0; border-radius: 50%; cursor: pointer; color: var(--white); background: var(--green-dark); box-shadow: 0 12px 32px rgba(38, 61, 44, 0.28); font-size: 1.35rem; transition: transform 150ms ease, background 150ms ease; }
|
||||
.scroll-to-top:hover { transform: translateY(-2px); background: var(--green); }
|
||||
.scroll-to-top:focus-visible { outline: 3px solid var(--orange); outline-offset: 3px; }
|
||||
.scroll-to-top[hidden] { display: none; }
|
||||
|
||||
.toast { position: fixed; z-index: 50; right: 24px; bottom: 24px; max-width: min(420px, calc(100% - 48px)); padding: 15px 20px; border-radius: 12px; color: var(--white); background: var(--green-dark); box-shadow: 0 14px 40px rgba(0, 0, 0, 0.2); }
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.import-panel, .preview-layout, .detail-hero, .detail-columns { grid-template-columns: 1fr; }
|
||||
.status { grid-column: 1; }
|
||||
.recipe-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.detail-intro { min-height: 380px; }
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.site-header { min-height: 66px; gap: 8px; padding-inline: 12px; }
|
||||
.brand { min-width: 0; gap: 8px; font-size: clamp(0.86rem, 3.8vw, 1rem); }
|
||||
.brand-mark-small { width: 36px; height: 36px; flex: 0 0 auto; font-size: 0.62rem; }
|
||||
.brand > span:last-child { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.profile { min-width: 0; gap: 6px; }
|
||||
.profile #profile-name { display: block; overflow: hidden; max-width: clamp(44px, 15vw, 90px); font-size: 0.72rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.profile .avatar { display: block; width: 28px; height: 28px; flex: 0 0 auto; }
|
||||
.profile .menu-button { width: 38px; height: 38px; flex: 0 0 auto; padding: 8px; }
|
||||
.profile .button-quiet { min-height: 36px; padding: 6px 9px; font-size: 0.72rem; }
|
||||
.page-shell, .detail-shell { width: min(100% - 24px, 1240px); }
|
||||
.hero { padding-top: 70px; }
|
||||
.hero h1 { font-size: clamp(1.45rem, 8.5vw, 4.5rem); }
|
||||
.import-panel, .preview-panel { padding: 24px 18px; border-radius: 20px; }
|
||||
.import-form { flex-direction: column; border-radius: 18px; }
|
||||
.import-form .button { width: 100%; }
|
||||
.recipe-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||
.recipe-card { border-radius: 14px; }
|
||||
.recipe-card-body { padding: 14px; }
|
||||
.recipe-card-body h3 { display: -webkit-box; overflow: hidden; margin: 10px 0 6px; font-size: 1.15rem; -webkit-box-orient: vertical; -webkit-line-clamp: 3; }
|
||||
.recipe-card-body > p:not(.recipe-card-match) { display: -webkit-box; overflow: hidden; font-size: 0.75rem; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
|
||||
.recipe-card-tags { gap: 4px; margin-top: 10px; }
|
||||
.recipe-card-tags .tag { font-size: 0.68rem; }
|
||||
.tag-filter-heading { align-items: flex-start; flex-direction: column; }
|
||||
.tag-filter-heading small { display: block; margin: 2px 0 0; }
|
||||
.recipe-results { min-height: 340px; }
|
||||
.recipe-search { border-radius: 18px; }
|
||||
.access-drawer { padding: 26px 20px; }
|
||||
.access-overlay { width: calc(100% - 24px); max-height: calc(100dvh - 24px); padding: 26px 20px; border-radius: 20px; }
|
||||
.editor-fields.two-column { grid-template-columns: 1fr; }
|
||||
.ingredient-row { grid-template-columns: 1fr 0.7fr 34px; }
|
||||
.step-row { grid-template-columns: 32px 1fr 30px; }
|
||||
.step-row [data-step-time] { grid-column: 2; }
|
||||
.detail-shell { padding-top: 28px; }
|
||||
.detail-hero { min-height: 0; }
|
||||
.detail-image { min-height: 300px; }
|
||||
.detail-columns { padding: 34px 22px; }
|
||||
.detail-footer { align-items: flex-start; flex-direction: column; padding: 24px 22px; }
|
||||
.scroll-to-top { right: 16px; bottom: 16px; width: 48px; height: 48px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { scroll-behavior: auto !important; transition: none !important; }
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="개인 레시피 아카이브">
|
||||
<title>Our Recipe Atlas</title>
|
||||
<link rel="stylesheet" href="/css/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<a class="brand" href="/" aria-label="Our Recipe Atlas 홈">
|
||||
<span class="brand-mark brand-mark-small" aria-hidden="true">ORA</span>
|
||||
<span>Our Recipe Atlas</span>
|
||||
</a>
|
||||
<div class="profile">
|
||||
<img id="profile-image" class="avatar" alt="" hidden>
|
||||
<span id="profile-name"></span>
|
||||
<button id="access-menu-button" class="menu-button" type="button"
|
||||
aria-label="메뉴 열기" aria-controls="access-drawer"
|
||||
aria-expanded="false">
|
||||
<span></span><span></span><span></span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="page-shell">
|
||||
<section class="hero">
|
||||
<p class="eyebrow">COLLECT · REFINE · COOK</p>
|
||||
<h1>
|
||||
<span>오늘 발견한</span>
|
||||
<span>레시피를</span>
|
||||
<span>나만의</span>
|
||||
<span>아틀라스에.</span>
|
||||
</h1>
|
||||
<p>Instagram Reel 또는 YouTube 영상 링크를 붙여 넣으면 재료와 조리법을 정리합니다.</p>
|
||||
</section>
|
||||
|
||||
<section class="import-panel" aria-labelledby="import-title">
|
||||
<div>
|
||||
<p class="section-number">01</p>
|
||||
<h2 id="import-title">새 레시피 가져오기</h2>
|
||||
</div>
|
||||
<form id="import-form" class="import-form">
|
||||
<label class="sr-only" for="source-url">Instagram 또는 YouTube URL</label>
|
||||
<input id="source-url" name="url" type="url" inputmode="url"
|
||||
placeholder="https://www.instagram.com/reel/..." required>
|
||||
<button class="button button-primary" type="submit">레시피 가져오기</button>
|
||||
</form>
|
||||
<div id="import-status" class="status" role="status" aria-live="polite" hidden></div>
|
||||
</section>
|
||||
|
||||
<section id="preview-section" class="preview-panel" aria-labelledby="preview-title" hidden>
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="section-number">02</p>
|
||||
<h2 id="preview-title">확인하고 다듬기</h2>
|
||||
</div>
|
||||
<button id="cancel-preview" class="button button-quiet" type="button">취소</button>
|
||||
</div>
|
||||
<form id="preview-form"></form>
|
||||
</section>
|
||||
|
||||
<section class="library" aria-labelledby="library-title">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="section-number">03</p>
|
||||
<h2 id="library-title">Recipe Library</h2>
|
||||
</div>
|
||||
<span id="recipe-count" class="count-label"></span>
|
||||
</div>
|
||||
<div class="recipe-search">
|
||||
<label class="sr-only" for="recipe-search-input">재료나 레시피 검색</label>
|
||||
<input id="recipe-search-input" type="search" autocomplete="off"
|
||||
placeholder="재료나 레시피 검색 (예: 감자)">
|
||||
<button id="clear-recipe-search" class="text-button" type="button" hidden>지우기</button>
|
||||
</div>
|
||||
<div id="tag-filter" class="tag-filter" hidden>
|
||||
<div class="tag-filter-heading">
|
||||
<span>태그 필터 <small>선택한 태그를 모두 포함</small></span>
|
||||
<div class="tag-filter-actions">
|
||||
<button id="toggle-tag-filter" class="text-button" type="button"
|
||||
aria-expanded="false" hidden></button>
|
||||
<button id="clear-tag-filter" class="text-button" type="button" hidden>전체 보기</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tag-filter-options" class="tag-filter-options" aria-label="태그 필터"></div>
|
||||
</div>
|
||||
<div class="recipe-results">
|
||||
<div id="recipe-grid" class="recipe-grid" aria-live="polite"></div>
|
||||
<p id="empty-library" class="empty-state" hidden>아직 저장한 레시피가 없습니다.<br>첫 링크를 가져와 보세요.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<button id="drawer-backdrop" class="drawer-backdrop" type="button"
|
||||
aria-label="메뉴 닫기" hidden></button>
|
||||
<aside id="access-drawer" class="access-drawer" role="dialog" aria-modal="true"
|
||||
aria-labelledby="drawer-title" aria-hidden="true" hidden>
|
||||
<div class="drawer-heading">
|
||||
<div>
|
||||
<p class="section-number">MENU</p>
|
||||
<h2 id="drawer-title">메뉴</h2>
|
||||
</div>
|
||||
<button id="close-access-drawer" class="drawer-close" type="button"
|
||||
aria-label="메뉴 닫기">×</button>
|
||||
</div>
|
||||
<div class="drawer-account">
|
||||
<strong id="drawer-user-name"></strong>
|
||||
<span id="drawer-user-email"></span>
|
||||
</div>
|
||||
<div class="drawer-actions">
|
||||
<button id="open-access-overlay" class="drawer-action" type="button" hidden>
|
||||
<span>로그인 허용 계정 관리</span><small>관리자</small>
|
||||
</button>
|
||||
<button id="drawer-logout" class="drawer-action danger" type="button" data-logout>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<button id="access-overlay-backdrop" class="access-overlay-backdrop" type="button"
|
||||
aria-label="로그인 허용 계정 관리 닫기" hidden></button>
|
||||
<section id="access-overlay" class="access-overlay" role="dialog" aria-modal="true"
|
||||
aria-labelledby="access-title" aria-hidden="true" hidden>
|
||||
<div class="drawer-heading">
|
||||
<div>
|
||||
<p class="section-number">ADMIN</p>
|
||||
<h2 id="access-title">로그인 허용 계정</h2>
|
||||
</div>
|
||||
<button id="close-access-overlay" class="drawer-close" type="button"
|
||||
aria-label="로그인 허용 계정 관리 닫기">×</button>
|
||||
</div>
|
||||
<p class="access-copy">Google 로그인을 허용할 이메일을 입력하세요.</p>
|
||||
<form id="access-form" class="access-form">
|
||||
<label class="sr-only" for="access-email">Google 계정 이메일</label>
|
||||
<input id="access-email" name="email" type="email" autocomplete="email"
|
||||
placeholder="name@gmail.com" required>
|
||||
<button class="button button-primary" type="submit">계정 추가</button>
|
||||
</form>
|
||||
<div id="allowed-email-list" class="allowed-email-list"></div>
|
||||
</section>
|
||||
|
||||
<button id="scroll-to-top" class="scroll-to-top" type="button"
|
||||
aria-label="맨 위로 이동" title="맨 위로" hidden>↑</button>
|
||||
<div id="toast" class="toast" role="alert" hidden></div>
|
||||
<script type="module" src="/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,21 @@
|
||||
export async function apiRequest(path, options = {}) {
|
||||
const response = await fetch(path, {
|
||||
...options,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
...(options.body ? { 'content-type': 'application/json' } : {}),
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
window.location.assign('/login');
|
||||
throw new Error('로그인이 필요합니다.');
|
||||
}
|
||||
|
||||
if (response.status === 204) return null;
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.error?.message ?? '요청을 처리하지 못했습니다.');
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
import { apiRequest } from './api.js';
|
||||
import { bindLogout, loadSession } from './auth.js';
|
||||
import { searchRecipes } from './recipe-search.js';
|
||||
import { bindScrollToTop } from './scroll-to-top.js';
|
||||
|
||||
const elements = {
|
||||
importForm: document.querySelector('#import-form'),
|
||||
importStatus: document.querySelector('#import-status'),
|
||||
previewSection: document.querySelector('#preview-section'),
|
||||
previewForm: document.querySelector('#preview-form'),
|
||||
recipeGrid: document.querySelector('#recipe-grid'),
|
||||
emptyLibrary: document.querySelector('#empty-library'),
|
||||
recipeCount: document.querySelector('#recipe-count'),
|
||||
recipeSearchInput: document.querySelector('#recipe-search-input'),
|
||||
clearRecipeSearch: document.querySelector('#clear-recipe-search'),
|
||||
tagFilter: document.querySelector('#tag-filter'),
|
||||
tagFilterOptions: document.querySelector('#tag-filter-options'),
|
||||
toggleTagFilter: document.querySelector('#toggle-tag-filter'),
|
||||
clearTagFilter: document.querySelector('#clear-tag-filter'),
|
||||
accessMenuButton: document.querySelector('#access-menu-button'),
|
||||
accessDrawer: document.querySelector('#access-drawer'),
|
||||
closeAccessDrawer: document.querySelector('#close-access-drawer'),
|
||||
drawerBackdrop: document.querySelector('#drawer-backdrop'),
|
||||
drawerUserName: document.querySelector('#drawer-user-name'),
|
||||
drawerUserEmail: document.querySelector('#drawer-user-email'),
|
||||
openAccessOverlay: document.querySelector('#open-access-overlay'),
|
||||
accessOverlay: document.querySelector('#access-overlay'),
|
||||
closeAccessOverlay: document.querySelector('#close-access-overlay'),
|
||||
accessOverlayBackdrop: document.querySelector('#access-overlay-backdrop'),
|
||||
accessForm: document.querySelector('#access-form'),
|
||||
accessEmail: document.querySelector('#access-email'),
|
||||
allowedEmailList: document.querySelector('#allowed-email-list'),
|
||||
scrollToTop: document.querySelector('#scroll-to-top'),
|
||||
toast: document.querySelector('#toast'),
|
||||
};
|
||||
|
||||
let previewData = null;
|
||||
let savedRecipes = [];
|
||||
let currentUser = null;
|
||||
let searchQuery = '';
|
||||
let searchTimer = null;
|
||||
let searchIsComposing = false;
|
||||
let showAllTags = false;
|
||||
const selectedTags = new Set();
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 180;
|
||||
const VISIBLE_TAG_LIMIT = 6;
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function safeImageUrl(value) {
|
||||
if (!value) return '';
|
||||
try {
|
||||
const url = new URL(value, window.location.origin);
|
||||
if (url.protocol === 'http:' || url.protocol === 'https:') return url.href;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function showToast(message) {
|
||||
elements.toast.textContent = message;
|
||||
elements.toast.hidden = false;
|
||||
window.setTimeout(() => { elements.toast.hidden = true; }, 3200);
|
||||
}
|
||||
|
||||
function setStatus(message, kind = '') {
|
||||
elements.importStatus.textContent = message;
|
||||
elements.importStatus.className = `status ${kind}`.trim();
|
||||
elements.importStatus.hidden = false;
|
||||
}
|
||||
|
||||
function recipeImage(recipe) {
|
||||
return recipe.imagePath ? `/media/${recipe.imagePath}` : '';
|
||||
}
|
||||
|
||||
function normalizeTag(value) {
|
||||
return String(value ?? '').trim().replace(/^#+\s*/, '').replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function tagKey(value) {
|
||||
return normalizeTag(value).toLocaleLowerCase();
|
||||
}
|
||||
|
||||
function tagsForRecipe(recipe) {
|
||||
const tags = new Map();
|
||||
for (const value of recipe.tags ?? []) {
|
||||
const tag = normalizeTag(value);
|
||||
if (tag) tags.set(tagKey(tag), tag);
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
function availableTags() {
|
||||
const tags = new Map();
|
||||
for (const recipe of savedRecipes) {
|
||||
for (const [key, label] of tagsForRecipe(recipe)) {
|
||||
const existing = tags.get(key);
|
||||
tags.set(key, { label: existing?.label ?? label, count: (existing?.count ?? 0) + 1 });
|
||||
}
|
||||
}
|
||||
return [...tags.entries()].sort(([, a], [, b]) => (
|
||||
b.count - a.count || a.label.localeCompare(b.label, 'ko')
|
||||
));
|
||||
}
|
||||
|
||||
function renderTagFilter() {
|
||||
const tags = availableTags();
|
||||
const selectedExtras = tags
|
||||
.slice(VISIBLE_TAG_LIMIT)
|
||||
.filter(([key]) => selectedTags.has(key));
|
||||
const visibleTags = showAllTags
|
||||
? tags
|
||||
: [...tags.slice(0, VISIBLE_TAG_LIMIT), ...selectedExtras];
|
||||
const hiddenTagCount = tags.length - visibleTags.length;
|
||||
|
||||
elements.tagFilter.hidden = tags.length === 0;
|
||||
elements.clearTagFilter.hidden = selectedTags.size === 0;
|
||||
elements.toggleTagFilter.hidden = showAllTags
|
||||
? tags.length <= VISIBLE_TAG_LIMIT
|
||||
: hiddenTagCount === 0;
|
||||
elements.toggleTagFilter.textContent = showAllTags
|
||||
? '접기'
|
||||
: `+ ${hiddenTagCount}개 더보기`;
|
||||
elements.toggleTagFilter.setAttribute('aria-expanded', String(showAllTags));
|
||||
elements.tagFilterOptions.innerHTML = visibleTags.map(([key, { label, count }]) => `
|
||||
<button class="tag-filter-button" type="button" data-tag="${escapeHtml(key)}"
|
||||
aria-pressed="${selectedTags.has(key)}">#${escapeHtml(label)} <span>${count}</span></button>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderRecipes() {
|
||||
const tagFilteredRecipes = selectedTags.size === 0
|
||||
? savedRecipes
|
||||
: savedRecipes.filter((recipe) => {
|
||||
const recipeTags = tagsForRecipe(recipe);
|
||||
return [...selectedTags].every((tag) => recipeTags.has(tag));
|
||||
});
|
||||
const matches = searchRecipes(tagFilteredRecipes, searchQuery);
|
||||
const hasFilters = selectedTags.size > 0 || searchQuery.length > 0;
|
||||
|
||||
elements.recipeCount.textContent = hasFilters
|
||||
? `${matches.length} / ${savedRecipes.length} RECIPES`
|
||||
: `${matches.length} RECIPES`;
|
||||
elements.emptyLibrary.hidden = matches.length !== 0;
|
||||
elements.emptyLibrary.innerHTML = savedRecipes.length === 0
|
||||
? '아직 저장한 레시피가 없습니다.<br>첫 링크를 가져와 보세요.'
|
||||
: '검색 조건에 맞는 레시피가 없습니다.';
|
||||
elements.recipeGrid.innerHTML = matches.map(({ recipe, matchedIngredients }) => {
|
||||
const image = safeImageUrl(recipeImage(recipe));
|
||||
const tags = [...tagsForRecipe(recipe).values()];
|
||||
return `<a class="recipe-card" href="/recipe/${encodeURIComponent(recipe._id)}">
|
||||
<div class="recipe-card-image">
|
||||
${image ? `<img src="${escapeHtml(image)}" alt="" loading="lazy">` : '<span>NO IMAGE</span>'}
|
||||
</div>
|
||||
<div class="recipe-card-body">
|
||||
<span class="platform-label">${escapeHtml(recipe.source.platform)}</span>
|
||||
<h3>${escapeHtml(recipe.title)}</h3>
|
||||
<p>${escapeHtml(recipe.summary || recipe.source.author || '')}</p>
|
||||
${matchedIngredients.length
|
||||
? `<p class="recipe-card-match">일치 재료: ${matchedIngredients.map(escapeHtml).join(', ')}</p>`
|
||||
: ''}
|
||||
${tags.length ? `<div class="recipe-card-tags">${tags.map((tag) => `<span class="tag">#${escapeHtml(tag)}</span>`).join('')}</div>` : ''}
|
||||
</div>
|
||||
</a>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function loadRecipes() {
|
||||
const { recipes } = await apiRequest('/api/recipes');
|
||||
savedRecipes = recipes;
|
||||
const currentTagKeys = new Set(availableTags().map(([key]) => key));
|
||||
for (const tag of selectedTags) {
|
||||
if (!currentTagKeys.has(tag)) selectedTags.delete(tag);
|
||||
}
|
||||
renderTagFilter();
|
||||
renderRecipes();
|
||||
}
|
||||
|
||||
async function loadAllowedEmails() {
|
||||
const { emails } = await apiRequest('/api/allowed-emails');
|
||||
elements.allowedEmailList.innerHTML = emails.map((email) => {
|
||||
const isCurrent = email === currentUser.email.toLowerCase();
|
||||
return `<div class="allowed-email-row">
|
||||
<span>${escapeHtml(email)}</span>
|
||||
${isCurrent
|
||||
? '<small>현재 관리자</small>'
|
||||
: `<button class="text-button danger" type="button" data-remove-email="${escapeHtml(email)}">삭제</button>`}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function updatePageLock() {
|
||||
document.body.classList.toggle(
|
||||
'drawer-open',
|
||||
!elements.accessDrawer.hidden || !elements.accessOverlay.hidden,
|
||||
);
|
||||
}
|
||||
|
||||
function setAccessDrawerOpen(open) {
|
||||
elements.accessMenuButton.setAttribute('aria-expanded', String(open));
|
||||
elements.accessDrawer.setAttribute('aria-hidden', String(!open));
|
||||
elements.accessDrawer.hidden = !open;
|
||||
elements.drawerBackdrop.hidden = !open;
|
||||
updatePageLock();
|
||||
if (open) elements.closeAccessDrawer.focus();
|
||||
else elements.accessMenuButton.focus();
|
||||
}
|
||||
|
||||
function setAccessOverlayOpen(open) {
|
||||
elements.accessOverlay.setAttribute('aria-hidden', String(!open));
|
||||
elements.accessOverlay.hidden = !open;
|
||||
elements.accessOverlayBackdrop.hidden = !open;
|
||||
updatePageLock();
|
||||
if (open) elements.accessEmail.focus();
|
||||
else elements.accessMenuButton.focus();
|
||||
}
|
||||
|
||||
function ingredientGroupTemplate(group, groupIndex) {
|
||||
return `<fieldset class="editor-group" data-ingredient-group>
|
||||
<div class="editor-group-heading">
|
||||
<input aria-label="재료 그룹 이름" data-group-name value="${escapeHtml(group.name)}" required>
|
||||
<button class="text-button danger" type="button" data-action="remove-group" data-group="${groupIndex}">그룹 삭제</button>
|
||||
</div>
|
||||
<div class="ingredient-list">
|
||||
${group.items.map((item, itemIndex) => `<div class="ingredient-row" data-ingredient-item>
|
||||
<input aria-label="재료 이름" data-item-name value="${escapeHtml(item.name)}" placeholder="재료" required>
|
||||
<input aria-label="재료 수량" data-item-amount value="${escapeHtml(item.amount || '')}" placeholder="수량">
|
||||
<button class="icon-button" type="button" aria-label="재료 삭제" data-action="remove-item" data-group="${groupIndex}" data-item="${itemIndex}">×</button>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
<button class="text-button" type="button" data-action="add-item" data-group="${groupIndex}">+ 재료 추가</button>
|
||||
</fieldset>`;
|
||||
}
|
||||
|
||||
function renderPreview() {
|
||||
const { recipe, imagePreviewUrl, source } = previewData;
|
||||
const image = safeImageUrl(imagePreviewUrl);
|
||||
elements.previewForm.innerHTML = `
|
||||
<div class="preview-layout">
|
||||
<div class="preview-image">
|
||||
${image ? `<img src="${escapeHtml(image)}" alt="가져온 레시피 미리보기">` : '<span>NO IMAGE</span>'}
|
||||
<span class="platform-label">${escapeHtml(source.platform)}</span>
|
||||
</div>
|
||||
<div class="editor-fields">
|
||||
<label>제목<input id="recipe-title" value="${escapeHtml(recipe.title)}" required></label>
|
||||
<label>한 줄 설명<textarea id="recipe-summary" rows="2">${escapeHtml(recipe.summary || '')}</textarea></label>
|
||||
<label>분량<input id="recipe-servings" value="${escapeHtml(recipe.servings || '')}"></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-section">
|
||||
<div class="editor-section-heading"><h3>재료</h3><button class="text-button" type="button" data-action="add-group">+ 그룹 추가</button></div>
|
||||
<div id="ingredient-groups">${recipe.ingredientGroups.map(ingredientGroupTemplate).join('')}</div>
|
||||
</div>
|
||||
<div class="editor-section">
|
||||
<div class="editor-section-heading"><h3>조리 순서</h3><button class="text-button" type="button" data-action="add-step">+ 단계 추가</button></div>
|
||||
<div id="step-list">${recipe.steps.map((step, index) => `<div class="step-row" data-step>
|
||||
<span>${index + 1}</span>
|
||||
<textarea data-step-text rows="2" required>${escapeHtml(step.text)}</textarea>
|
||||
<input data-step-time type="number" min="0" step="1" value="${step.timestampSec ?? ''}" placeholder="초">
|
||||
<button class="icon-button" type="button" aria-label="단계 삭제" data-action="remove-step" data-step-index="${index}">×</button>
|
||||
</div>`).join('')}</div>
|
||||
</div>
|
||||
<div class="editor-fields two-column">
|
||||
<label>팁 (한 줄에 하나)<textarea id="recipe-tips" rows="3">${escapeHtml(recipe.tips.join('\n'))}</textarea></label>
|
||||
<label>태그 (표준 태그 중 최대 3개, 쉼표로 구분)<textarea id="recipe-tags" rows="3">${escapeHtml(recipe.tags.join(', '))}</textarea></label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="button button-primary" type="submit">아틀라스에 저장</button>
|
||||
</div>`;
|
||||
elements.previewSection.hidden = false;
|
||||
elements.previewSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
function readPreviewForm() {
|
||||
const ingredientGroups = [...elements.previewForm.querySelectorAll('[data-ingredient-group]')].map((group) => ({
|
||||
name: group.querySelector('[data-group-name]').value.trim(),
|
||||
items: [...group.querySelectorAll('[data-ingredient-item]')].map((item) => ({
|
||||
name: item.querySelector('[data-item-name]').value.trim(),
|
||||
amount: item.querySelector('[data-item-amount]').value.trim() || null,
|
||||
})),
|
||||
}));
|
||||
const steps = [...elements.previewForm.querySelectorAll('[data-step]')].map((step, index) => {
|
||||
const timestamp = step.querySelector('[data-step-time]').value;
|
||||
return {
|
||||
order: index + 1,
|
||||
text: step.querySelector('[data-step-text]').value.trim(),
|
||||
timestampSec: timestamp === '' ? null : Number(timestamp),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
title: document.querySelector('#recipe-title').value.trim(),
|
||||
summary: document.querySelector('#recipe-summary').value.trim() || null,
|
||||
servings: document.querySelector('#recipe-servings').value.trim() || null,
|
||||
ingredientGroups,
|
||||
steps,
|
||||
tips: document.querySelector('#recipe-tips').value.split(/\r?\n/).map((item) => item.trim()).filter(Boolean),
|
||||
tags: document.querySelector('#recipe-tags').value.split(',').map((item) => item.trim()).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
function mutatePreview(action, button) {
|
||||
previewData.recipe = readPreviewForm();
|
||||
const groupIndex = Number(button.dataset.group);
|
||||
const itemIndex = Number(button.dataset.item);
|
||||
const stepIndex = Number(button.dataset.stepIndex);
|
||||
|
||||
if (action === 'add-group') previewData.recipe.ingredientGroups.push({ name: '재료', items: [] });
|
||||
if (action === 'remove-group') previewData.recipe.ingredientGroups.splice(groupIndex, 1);
|
||||
if (action === 'add-item') previewData.recipe.ingredientGroups[groupIndex].items.push({ name: '', amount: null });
|
||||
if (action === 'remove-item') previewData.recipe.ingredientGroups[groupIndex].items.splice(itemIndex, 1);
|
||||
if (action === 'add-step') previewData.recipe.steps.push({ order: previewData.recipe.steps.length + 1, text: '', timestampSec: null });
|
||||
if (action === 'remove-step') previewData.recipe.steps.splice(stepIndex, 1);
|
||||
previewData.recipe.steps.forEach((step, index) => { step.order = index + 1; });
|
||||
renderPreview();
|
||||
}
|
||||
|
||||
elements.importForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const button = elements.importForm.querySelector('button');
|
||||
button.disabled = true;
|
||||
const timers = [
|
||||
window.setTimeout(() => setStatus('콘텐츠 가져오는 중...'), 500),
|
||||
window.setTimeout(() => setStatus('레시피 분석 중...'), 1600),
|
||||
];
|
||||
setStatus('링크 확인 중...');
|
||||
|
||||
try {
|
||||
const url = new FormData(elements.importForm).get('url');
|
||||
previewData = await apiRequest('/api/import/preview', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
timers.forEach(window.clearTimeout);
|
||||
setStatus('분석 완료', 'success');
|
||||
renderPreview();
|
||||
} catch (error) {
|
||||
timers.forEach(window.clearTimeout);
|
||||
setStatus(error.message, 'error');
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
elements.previewForm.addEventListener('click', (event) => {
|
||||
const button = event.target.closest('[data-action]');
|
||||
if (button) mutatePreview(button.dataset.action, button);
|
||||
});
|
||||
|
||||
elements.previewForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const button = elements.previewForm.querySelector('[type="submit"]');
|
||||
button.disabled = true;
|
||||
try {
|
||||
const { recipe } = await apiRequest('/api/recipes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
recipe: readPreviewForm(),
|
||||
source: previewData.source,
|
||||
imagePreviewUrl: previewData.imagePreviewUrl,
|
||||
}),
|
||||
});
|
||||
previewData = null;
|
||||
elements.previewSection.hidden = true;
|
||||
elements.importForm.reset();
|
||||
showToast(`${recipe.title} 레시피를 저장했습니다.`);
|
||||
await loadRecipes();
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector('#cancel-preview').addEventListener('click', () => {
|
||||
previewData = null;
|
||||
elements.previewSection.hidden = true;
|
||||
});
|
||||
|
||||
elements.tagFilterOptions.addEventListener('click', (event) => {
|
||||
const button = event.target.closest('[data-tag]');
|
||||
if (!button) return;
|
||||
const key = button.dataset.tag;
|
||||
if (selectedTags.has(key)) selectedTags.delete(key);
|
||||
else selectedTags.add(key);
|
||||
renderTagFilter();
|
||||
renderRecipes();
|
||||
});
|
||||
|
||||
elements.clearTagFilter.addEventListener('click', () => {
|
||||
selectedTags.clear();
|
||||
showAllTags = false;
|
||||
renderTagFilter();
|
||||
renderRecipes();
|
||||
});
|
||||
|
||||
elements.toggleTagFilter.addEventListener('click', () => {
|
||||
showAllTags = !showAllTags;
|
||||
renderTagFilter();
|
||||
});
|
||||
|
||||
function scheduleRecipeSearch() {
|
||||
const nextQuery = elements.recipeSearchInput.value.trim();
|
||||
elements.clearRecipeSearch.hidden = nextQuery.length === 0;
|
||||
window.clearTimeout(searchTimer);
|
||||
searchTimer = window.setTimeout(() => {
|
||||
searchQuery = nextQuery;
|
||||
searchTimer = null;
|
||||
renderRecipes();
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
elements.recipeSearchInput.addEventListener('compositionstart', () => {
|
||||
searchIsComposing = true;
|
||||
});
|
||||
|
||||
elements.recipeSearchInput.addEventListener('compositionend', () => {
|
||||
searchIsComposing = false;
|
||||
scheduleRecipeSearch();
|
||||
});
|
||||
|
||||
elements.recipeSearchInput.addEventListener('input', (event) => {
|
||||
elements.clearRecipeSearch.hidden = elements.recipeSearchInput.value.trim().length === 0;
|
||||
if (searchIsComposing || event.isComposing) return;
|
||||
scheduleRecipeSearch();
|
||||
});
|
||||
|
||||
elements.clearRecipeSearch.addEventListener('click', () => {
|
||||
window.clearTimeout(searchTimer);
|
||||
searchTimer = null;
|
||||
elements.recipeSearchInput.value = '';
|
||||
searchQuery = '';
|
||||
elements.clearRecipeSearch.hidden = true;
|
||||
elements.recipeSearchInput.focus();
|
||||
renderRecipes();
|
||||
});
|
||||
|
||||
elements.accessMenuButton.addEventListener('click', () => {
|
||||
setAccessDrawerOpen(true);
|
||||
});
|
||||
|
||||
elements.openAccessOverlay.addEventListener('click', async () => {
|
||||
setAccessDrawerOpen(false);
|
||||
setAccessOverlayOpen(true);
|
||||
try {
|
||||
await loadAllowedEmails();
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
elements.closeAccessDrawer.addEventListener('click', () => setAccessDrawerOpen(false));
|
||||
elements.drawerBackdrop.addEventListener('click', () => setAccessDrawerOpen(false));
|
||||
elements.closeAccessOverlay.addEventListener('click', () => setAccessOverlayOpen(false));
|
||||
elements.accessOverlayBackdrop.addEventListener('click', () => setAccessOverlayOpen(false));
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
if (!elements.accessOverlay.hidden) setAccessOverlayOpen(false);
|
||||
else if (!elements.accessDrawer.hidden) setAccessDrawerOpen(false);
|
||||
});
|
||||
|
||||
elements.accessForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const button = elements.accessForm.querySelector('button');
|
||||
button.disabled = true;
|
||||
try {
|
||||
const email = new FormData(elements.accessForm).get('email');
|
||||
await apiRequest('/api/allowed-emails', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
elements.accessForm.reset();
|
||||
showToast(`${email} 계정을 추가했습니다.`);
|
||||
await loadAllowedEmails();
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
elements.allowedEmailList.addEventListener('click', async (event) => {
|
||||
const button = event.target.closest('[data-remove-email]');
|
||||
if (!button) return;
|
||||
button.disabled = true;
|
||||
try {
|
||||
await apiRequest(`/api/allowed-emails/${encodeURIComponent(button.dataset.removeEmail)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
showToast(`${button.dataset.removeEmail} 계정을 삭제했습니다.`);
|
||||
await loadAllowedEmails();
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
bindLogout();
|
||||
bindScrollToTop(elements.scrollToTop);
|
||||
currentUser = await loadSession();
|
||||
elements.drawerUserName.textContent = currentUser.name || currentUser.email;
|
||||
elements.drawerUserEmail.textContent = currentUser.email;
|
||||
await loadRecipes();
|
||||
if (currentUser.canManageAccess) {
|
||||
elements.openAccessOverlay.hidden = false;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { apiRequest } from './api.js';
|
||||
|
||||
export async function loadSession() {
|
||||
const { user } = await apiRequest('/auth/me');
|
||||
const name = document.querySelector('#profile-name');
|
||||
if (name) name.textContent = user.name || user.email;
|
||||
|
||||
const image = document.querySelector('#profile-image');
|
||||
if (image && user.picture) {
|
||||
image.src = user.picture;
|
||||
image.alt = `${user.name || user.email} 프로필`;
|
||||
image.hidden = false;
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
export function bindLogout() {
|
||||
document.querySelectorAll('[data-logout]').forEach((button) => {
|
||||
button.addEventListener('click', async () => {
|
||||
await apiRequest('/auth/logout', { method: 'POST' });
|
||||
window.location.assign('/login');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
function normalizeSearchText(value) {
|
||||
return String(value ?? '').trim().toLocaleLowerCase().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function ingredientNames(recipe) {
|
||||
return (recipe.ingredientGroups ?? [])
|
||||
.flatMap((group) => group.items ?? [])
|
||||
.map((item) => item.name)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function rankRecipeMatch(recipe, query) {
|
||||
const normalizedQuery = normalizeSearchText(query);
|
||||
if (!normalizedQuery) return { score: 0, matchedIngredients: [] };
|
||||
|
||||
const ingredients = ingredientNames(recipe);
|
||||
const matchedIngredients = ingredients.filter((name) => (
|
||||
normalizeSearchText(name).includes(normalizedQuery)
|
||||
));
|
||||
const hasExactIngredient = matchedIngredients.some((name) => (
|
||||
normalizeSearchText(name) === normalizedQuery
|
||||
));
|
||||
const title = normalizeSearchText(recipe.title);
|
||||
const tags = (recipe.tags ?? []).map(normalizeSearchText);
|
||||
const summary = normalizeSearchText(recipe.summary);
|
||||
|
||||
let score = 0;
|
||||
if (hasExactIngredient) score = 500;
|
||||
else if (matchedIngredients.length > 0) score = 400;
|
||||
else if (title === normalizedQuery) score = 350;
|
||||
else if (title.includes(normalizedQuery)) score = 300;
|
||||
else if (tags.includes(normalizedQuery)) score = 250;
|
||||
else if (tags.some((tag) => tag.includes(normalizedQuery))) score = 200;
|
||||
else if (summary.includes(normalizedQuery)) score = 100;
|
||||
|
||||
return { score, matchedIngredients };
|
||||
}
|
||||
|
||||
export function searchRecipes(recipes, query) {
|
||||
if (!normalizeSearchText(query)) {
|
||||
return recipes.map((recipe) => ({ recipe, score: 0, matchedIngredients: [] }));
|
||||
}
|
||||
|
||||
return recipes
|
||||
.map((recipe, index) => ({ recipe, index, ...rankRecipeMatch(recipe, query) }))
|
||||
.filter(({ score }) => score > 0)
|
||||
.sort((a, b) => b.score - a.score || a.index - b.index)
|
||||
.map(({ recipe, score, matchedIngredients }) => ({ recipe, score, matchedIngredients }));
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { apiRequest } from './api.js';
|
||||
import { bindLogout, loadSession } from './auth.js';
|
||||
import { bindScrollToTop } from './scroll-to-top.js';
|
||||
|
||||
const detail = document.querySelector('#recipe-detail');
|
||||
const toast = document.querySelector('#toast');
|
||||
const scrollToTop = document.querySelector('#scroll-to-top');
|
||||
let currentRecipe = null;
|
||||
let toastTimer;
|
||||
|
||||
bindScrollToTop(scrollToTop);
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function youtubeTimestampUrl(sourceUrl, seconds) {
|
||||
try {
|
||||
const url = new URL(sourceUrl);
|
||||
url.searchParams.set('t', `${Math.round(seconds)}s`);
|
||||
return url.href;
|
||||
} catch {
|
||||
return sourceUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(message) {
|
||||
window.clearTimeout(toastTimer);
|
||||
toast.textContent = message;
|
||||
toast.hidden = false;
|
||||
toastTimer = window.setTimeout(() => { toast.hidden = true; }, 3200);
|
||||
}
|
||||
|
||||
function ingredientGroupTemplate(group, groupIndex) {
|
||||
return `<fieldset class="editor-group" data-ingredient-group>
|
||||
<div class="editor-group-heading">
|
||||
<input aria-label="재료 그룹 이름" data-group-name value="${escapeHtml(group.name)}" required>
|
||||
<button class="text-button danger" type="button" data-action="remove-group" data-group="${groupIndex}">그룹 삭제</button>
|
||||
</div>
|
||||
<div class="ingredient-list">
|
||||
${group.items.map((item, itemIndex) => `<div class="ingredient-row" data-ingredient-item>
|
||||
<input aria-label="재료 이름" data-item-name value="${escapeHtml(item.name)}" placeholder="재료" required>
|
||||
<input aria-label="재료 수량" data-item-amount value="${escapeHtml(item.amount || '')}" placeholder="수량">
|
||||
<button class="icon-button" type="button" aria-label="재료 삭제" data-action="remove-item" data-group="${groupIndex}" data-item="${itemIndex}">×</button>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
<button class="text-button" type="button" data-action="add-item" data-group="${groupIndex}">+ 재료 추가</button>
|
||||
</fieldset>`;
|
||||
}
|
||||
|
||||
function readRecipeForm(form) {
|
||||
const ingredientGroups = [...form.querySelectorAll('[data-ingredient-group]')].map((group) => ({
|
||||
name: group.querySelector('[data-group-name]').value.trim(),
|
||||
items: [...group.querySelectorAll('[data-ingredient-item]')].map((item) => ({
|
||||
name: item.querySelector('[data-item-name]').value.trim(),
|
||||
amount: item.querySelector('[data-item-amount]').value.trim() || null,
|
||||
})),
|
||||
}));
|
||||
const steps = [...form.querySelectorAll('[data-step]')].map((step, index) => {
|
||||
const timestamp = step.querySelector('[data-step-time]').value;
|
||||
return {
|
||||
order: index + 1,
|
||||
text: step.querySelector('[data-step-text]').value.trim(),
|
||||
timestampSec: timestamp === '' ? null : Number(timestamp),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
title: form.querySelector('#recipe-title').value.trim(),
|
||||
summary: form.querySelector('#recipe-summary').value.trim() || null,
|
||||
servings: form.querySelector('#recipe-servings').value.trim() || null,
|
||||
ingredientGroups,
|
||||
steps,
|
||||
tips: form.querySelector('#recipe-tips').value.split(/\r?\n/).map((item) => item.trim()).filter(Boolean),
|
||||
tags: form.querySelector('#recipe-tags').value.split(',').map((item) => item.trim()).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
function renderRecipeEditor(recipe) {
|
||||
const image = recipe.imagePath ? `/media/${recipe.imagePath}` : null;
|
||||
detail.innerHTML = `
|
||||
<a class="back-link" href="/">← Recipe Library</a>
|
||||
<form id="recipe-edit-form" class="preview-panel recipe-edit-panel">
|
||||
<div class="section-heading">
|
||||
<div><p class="section-number">EDIT RECIPE</p><h2>레시피 수정</h2></div>
|
||||
<button id="cancel-edit" class="button button-quiet" type="button">취소</button>
|
||||
</div>
|
||||
<div class="preview-layout">
|
||||
<div class="preview-image">
|
||||
${image ? `<img src="${escapeHtml(image)}" alt="">` : '<span>NO IMAGE</span>'}
|
||||
<span class="platform-label">${escapeHtml(recipe.source.platform)}</span>
|
||||
</div>
|
||||
<div class="editor-fields">
|
||||
<label>제목<input id="recipe-title" value="${escapeHtml(recipe.title)}" required></label>
|
||||
<label>한 줄 설명<textarea id="recipe-summary" rows="2">${escapeHtml(recipe.summary || '')}</textarea></label>
|
||||
<label>분량<input id="recipe-servings" value="${escapeHtml(recipe.servings || '')}"></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-section">
|
||||
<div class="editor-section-heading"><h3>재료</h3><button class="text-button" type="button" data-action="add-group">+ 그룹 추가</button></div>
|
||||
<div>${recipe.ingredientGroups.map(ingredientGroupTemplate).join('')}</div>
|
||||
</div>
|
||||
<div class="editor-section">
|
||||
<div class="editor-section-heading"><h3>조리 순서</h3><button class="text-button" type="button" data-action="add-step">+ 단계 추가</button></div>
|
||||
<div>${recipe.steps.map((step, index) => `<div class="step-row" data-step>
|
||||
<span>${index + 1}</span>
|
||||
<textarea data-step-text rows="2" required>${escapeHtml(step.text)}</textarea>
|
||||
<input data-step-time type="number" min="0" step="1" value="${step.timestampSec ?? ''}" placeholder="초">
|
||||
<button class="icon-button" type="button" aria-label="단계 삭제" data-action="remove-step" data-step-index="${index}">×</button>
|
||||
</div>`).join('')}</div>
|
||||
</div>
|
||||
<div class="editor-fields two-column">
|
||||
<label>팁 (한 줄에 하나)<textarea id="recipe-tips" rows="3">${escapeHtml((recipe.tips ?? []).join('\n'))}</textarea></label>
|
||||
<label>태그 (쉼표로 구분, 기존 표현 재사용 권장)<textarea id="recipe-tags" rows="3">${escapeHtml((recipe.tags ?? []).join(', '))}</textarea></label>
|
||||
</div>
|
||||
<div class="form-actions"><button class="button button-primary" type="submit">변경사항 저장</button></div>
|
||||
</form>`;
|
||||
|
||||
const form = document.querySelector('#recipe-edit-form');
|
||||
document.querySelector('#cancel-edit').addEventListener('click', () => renderRecipe(currentRecipe));
|
||||
|
||||
form.addEventListener('click', (event) => {
|
||||
const button = event.target.closest('[data-action]');
|
||||
if (!button) return;
|
||||
const draft = { ...recipe, ...readRecipeForm(form) };
|
||||
const groupIndex = Number(button.dataset.group);
|
||||
const itemIndex = Number(button.dataset.item);
|
||||
const stepIndex = Number(button.dataset.stepIndex);
|
||||
|
||||
if (button.dataset.action === 'add-group') draft.ingredientGroups.push({ name: '재료', items: [] });
|
||||
if (button.dataset.action === 'remove-group') draft.ingredientGroups.splice(groupIndex, 1);
|
||||
if (button.dataset.action === 'add-item') draft.ingredientGroups[groupIndex].items.push({ name: '', amount: null });
|
||||
if (button.dataset.action === 'remove-item') draft.ingredientGroups[groupIndex].items.splice(itemIndex, 1);
|
||||
if (button.dataset.action === 'add-step') draft.steps.push({ order: draft.steps.length + 1, text: '', timestampSec: null });
|
||||
if (button.dataset.action === 'remove-step') draft.steps.splice(stepIndex, 1);
|
||||
draft.steps.forEach((step, index) => { step.order = index + 1; });
|
||||
renderRecipeEditor(draft);
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const button = form.querySelector('[type="submit"]');
|
||||
button.disabled = true;
|
||||
try {
|
||||
const { recipe: updatedRecipe } = await apiRequest(`/api/recipes/${encodeURIComponent(recipe._id)}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(readRecipeForm(form)),
|
||||
});
|
||||
currentRecipe = updatedRecipe;
|
||||
renderRecipe(currentRecipe);
|
||||
showToast('레시피를 수정했습니다.');
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderRecipe(recipe) {
|
||||
document.title = `${recipe.title} · Our Recipe Atlas`;
|
||||
const image = recipe.imagePath ? `/media/${recipe.imagePath}` : null;
|
||||
detail.innerHTML = `
|
||||
<a class="back-link" href="/">← Recipe Library</a>
|
||||
<article class="recipe-detail">
|
||||
<div class="detail-hero">
|
||||
<div class="detail-image">${image ? `<img src="${escapeHtml(image)}" alt="">` : '<span>NO IMAGE</span>'}</div>
|
||||
<div class="detail-intro">
|
||||
<span class="platform-label">${escapeHtml(recipe.source.platform)}</span>
|
||||
<h1>${escapeHtml(recipe.title)}</h1>
|
||||
${recipe.summary ? `<p>${escapeHtml(recipe.summary)}</p>` : ''}
|
||||
${recipe.servings ? `<p class="servings">분량 · ${escapeHtml(recipe.servings)}</p>` : ''}
|
||||
<div class="detail-actions">
|
||||
<button id="edit-recipe" class="button button-primary" type="button">레시피 수정</button>
|
||||
<a class="button button-secondary" href="${escapeHtml(recipe.source.url)}" target="_blank" rel="noreferrer">원본 보기 ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-columns">
|
||||
<section>
|
||||
<p class="section-number">INGREDIENTS</p>
|
||||
<h2>재료</h2>
|
||||
${recipe.ingredientGroups.map((group) => `<div class="ingredient-detail">
|
||||
<h3>${escapeHtml(group.name)}</h3>
|
||||
<ul>${group.items.map((item) => `<li><span>${escapeHtml(item.name)}</span><strong>${escapeHtml(item.amount || '')}</strong></li>`).join('')}</ul>
|
||||
</div>`).join('')}
|
||||
</section>
|
||||
<section>
|
||||
<p class="section-number">METHOD</p>
|
||||
<h2>조리 순서</h2>
|
||||
<ol class="method-list">${recipe.steps.map((step) => {
|
||||
const body = `<span>${escapeHtml(step.text)}</span>`;
|
||||
return `<li><strong>${String(step.order).padStart(2, '0')}</strong>${step.timestampSec != null && recipe.source.platform === 'youtube'
|
||||
? `<a href="${escapeHtml(youtubeTimestampUrl(recipe.source.url, step.timestampSec))}" target="_blank" rel="noreferrer">${body}<small>${Math.floor(step.timestampSec / 60)}:${String(Math.round(step.timestampSec) % 60).padStart(2, '0')} ↗</small></a>`
|
||||
: body}</li>`;
|
||||
}).join('')}</ol>
|
||||
${recipe.tips.length ? `<div class="tips"><h3>Tips</h3><ul>${recipe.tips.map((tip) => `<li>${escapeHtml(tip)}</li>`).join('')}</ul></div>` : ''}
|
||||
</section>
|
||||
</div>
|
||||
<footer class="detail-footer">
|
||||
<div>${recipe.tags.map((tag) => `<span class="tag">#${escapeHtml(tag)}</span>`).join('')}</div>
|
||||
<button id="delete-recipe" class="button button-danger" type="button">레시피 삭제</button>
|
||||
</footer>
|
||||
</article>`;
|
||||
|
||||
document.querySelector('#edit-recipe').addEventListener('click', () => renderRecipeEditor(recipe));
|
||||
|
||||
document.querySelector('#delete-recipe').addEventListener('click', async () => {
|
||||
if (!window.confirm(`'${recipe.title}' 레시피를 삭제할까요?`)) return;
|
||||
try {
|
||||
await apiRequest(`/api/recipes/${encodeURIComponent(recipe._id)}`, { method: 'DELETE' });
|
||||
window.location.assign('/');
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bindLogout();
|
||||
await loadSession();
|
||||
|
||||
try {
|
||||
const recipeId = decodeURIComponent(window.location.pathname.split('/').filter(Boolean).pop());
|
||||
const { recipe } = await apiRequest(`/api/recipes/${encodeURIComponent(recipeId)}`);
|
||||
currentRecipe = recipe;
|
||||
renderRecipe(currentRecipe);
|
||||
} catch (error) {
|
||||
detail.innerHTML = `<p class="empty-state">${escapeHtml(error.message)}</p><a class="button button-secondary" href="/">목록으로 돌아가기</a>`;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export function bindScrollToTop(button) {
|
||||
if (!button) return;
|
||||
|
||||
function updateVisibility() {
|
||||
button.hidden = window.scrollY <= 480;
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', updateVisibility, { passive: true });
|
||||
button.addEventListener('click', () => {
|
||||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
window.scrollTo({ top: 0, behavior: reduceMotion ? 'auto' : 'smooth' });
|
||||
});
|
||||
updateVisibility();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="Our Recipe Atlas 로그인">
|
||||
<title>로그인 · Our Recipe Atlas</title>
|
||||
<link rel="stylesheet" href="/css/app.css">
|
||||
</head>
|
||||
<body class="login-page">
|
||||
<main class="login-card">
|
||||
<div class="brand-mark" aria-hidden="true">ORA</div>
|
||||
<p class="eyebrow">PRIVATE RECIPE ARCHIVE</p>
|
||||
<h1>Our Recipe Atlas</h1>
|
||||
<p class="login-copy">Instagram과 YouTube에서 발견한 레시피를 한곳에 정리하세요.</p>
|
||||
<a class="button button-primary google-login" href="/auth/google">
|
||||
<span aria-hidden="true">G</span>
|
||||
Google 계정으로 계속하기
|
||||
</a>
|
||||
<p class="fine-print">허용 목록에 등록된 Google 계정만 로그인할 수 있습니다.</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Recipe · Our Recipe Atlas</title>
|
||||
<link rel="stylesheet" href="/css/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<a class="brand" href="/">
|
||||
<span class="brand-mark brand-mark-small" aria-hidden="true">ORA</span>
|
||||
<span>Our Recipe Atlas</span>
|
||||
</a>
|
||||
<div class="profile">
|
||||
<img id="profile-image" class="avatar" alt="" hidden>
|
||||
<span id="profile-name"></span>
|
||||
<button id="logout-button" class="button button-quiet" type="button" data-logout>로그아웃</button>
|
||||
</div>
|
||||
</header>
|
||||
<main id="recipe-detail" class="detail-shell">
|
||||
<p class="empty-state">레시피를 불러오는 중입니다.</p>
|
||||
</main>
|
||||
<button id="scroll-to-top" class="scroll-to-top" type="button"
|
||||
aria-label="맨 위로 이동" title="맨 위로" hidden>↑</button>
|
||||
<div id="toast" class="toast" role="alert" hidden></div>
|
||||
<script type="module" src="/js/recipe.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user