diff --git a/.gitignore b/.gitignore
index b32c1a5..d8fec7c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,4 +5,4 @@ node_modules/
data/images/*
!data/images/.gitkeep
coverage/
-package-lock.json
+package-lock.json
\ No newline at end of file
diff --git a/deploy/nginx.example.conf b/deploy/nginx.example.conf
index 950b596..66a6a82 100644
--- a/deploy/nginx.example.conf
+++ b/deploy/nginx.example.conf
@@ -1,6 +1,8 @@
server {
listen 80;
- server_name recipe.example.com;
+ listen [::]:80;
+
+ server_name recipeatlas.horoli.kr;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
@@ -12,25 +14,37 @@ server {
}
server {
- listen 443 ssl http2;
- server_name recipe.example.com;
+ listen 443 ssl;
+ listen [::]:443 ssl http2;
- # 실제 인증서 경로로 교체한다. Let's Encrypt/Caddy를 사용해도 된다.
- ssl_certificate /etc/letsencrypt/live/recipe.example.com/fullchain.pem;
- ssl_certificate_key /etc/letsencrypt/live/recipe.example.com/privkey.pem;
+ server_name recipeatlas.horoli.kr;
+
+ ssl_certificate /etc/letsencrypt/live/horoli.kr/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/horoli.kr/privkey.pem;
+ include /etc/letsencrypt/options-ssl-nginx.conf;
+ ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
- client_max_body_size 2m;
+
+ client_max_body_size 10m;
+
+ access_log /var/log/nginx/recipeatlas.horoli.kr.access.log;
+ error_log /var/log/nginx/recipeatlas.horoli.kr.error.log;
location / {
- proxy_pass http://127.0.0.1:3000;
+ proxy_pass http://192.168.0.250:1771;
proxy_http_version 1.1;
+
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
- proxy_read_timeout 120s;
+ proxy_set_header X-Forwarded-Host $host;
+
+ proxy_redirect off;
+ proxy_connect_timeout 30s;
+ proxy_send_timeout 360s;
+ proxy_read_timeout 360s;
}
}
-
diff --git a/public/css/app.css b/public/css/app.css
index 151bcd3..5aab985 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -81,6 +81,7 @@ img { display: block; max-width: 100%; }
.profile { display: flex; align-items: center; gap: 12px; font-size: 0.86rem; }
.avatar { width: 32px; height: 32px; border-radius: 50%; object-fit: cover; }
+.profile .avatar[hidden] { display: none; }
.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); }
@@ -108,7 +109,7 @@ img { display: block; max-width: 100%; }
.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; }
+.google-login > span[aria-hidden] { 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; }
@@ -239,6 +240,9 @@ img { display: block; max-width: 100%; }
.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); }
+.login-alert { margin: -12px 0 20px; padding: 14px 16px; border: 1px solid rgba(164, 57, 40, 0.3); border-radius: 14px; color: #7f2e22; background: #fff3ed; text-align: left; }
+.login-alert strong { font-size: 0.9rem; }
+.login-alert p { margin: 5px 0 0; font-size: 0.76rem; line-height: 1.5; }
.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; }
@@ -300,11 +304,12 @@ img { display: block; max-width: 100%; }
.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 { display: flex; overflow: hidden; flex-direction: column; border-radius: 14px; }
+ .recipe-card-body { display: flex; height: 228px; flex: 0 0 228px; flex-direction: column; overflow: hidden; padding: 14px; }
+ .recipe-card-body h3 { display: -webkit-box; overflow: hidden; min-height: 2.58rem; max-height: 2.58rem; margin: 10px 0 6px; font-size: 1.15rem; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
+ .recipe-card-body > p:not(.recipe-card-match) { display: -webkit-box; overflow: hidden; min-height: 2.18rem; max-height: 2.18rem; font-size: 0.75rem; line-height: 1.45; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
+ .recipe-card-body .recipe-card-match { display: -webkit-box; overflow: hidden; max-height: 2.5rem; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
+ .recipe-card-tags { overflow: hidden; max-height: 3.4rem; gap: 4px; margin-top: auto; padding-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; }
diff --git a/public/index.html b/public/index.html
index 31f276c..36cbbe8 100644
--- a/public/index.html
+++ b/public/index.html
@@ -8,21 +8,7 @@
-
+
-
-
-
+ ORA
+ Our Recipe Atlas
+
+
+
![]()
+
+
+
`;
+
+ header.insertAdjacentHTML('afterend', `
+
+ `);
+
+ const elements = {
+ menuButton: document.querySelector('#access-menu-button'),
+ profileImage: document.querySelector('#profile-image'),
+ profileName: document.querySelector('#profile-name'),
+ drawer: document.querySelector('#access-drawer'),
+ closeButton: document.querySelector('#close-access-drawer'),
+ backdrop: document.querySelector('#drawer-backdrop'),
+ manageAccessButton: document.querySelector('#open-access-overlay'),
+ drawerUserName: document.querySelector('#drawer-user-name'),
+ drawerUserEmail: document.querySelector('#drawer-user-email'),
+ };
+
+ function setOpen(open) {
+ elements.menuButton.setAttribute('aria-expanded', String(open));
+ elements.drawer.setAttribute('aria-hidden', String(!open));
+ elements.drawer.hidden = !open;
+ elements.backdrop.hidden = !open;
+ document.body.classList.toggle('drawer-open', open);
+ if (open) elements.closeButton.focus();
+ else elements.menuButton.focus();
+ }
+
+ elements.menuButton.addEventListener('click', () => setOpen(true));
+ elements.closeButton.addEventListener('click', () => setOpen(false));
+ elements.backdrop.addEventListener('click', () => setOpen(false));
+ elements.manageAccessButton.addEventListener('click', () => {
+ setOpen(false);
+ if (onManageAccess) onManageAccess();
+ else window.location.assign('/?manage-access=1');
+ });
+ document.addEventListener('keydown', (event) => {
+ if (event.key === 'Escape' && !elements.drawer.hidden) setOpen(false);
+ });
+
+ return {
+ focusMenuButton() {
+ elements.menuButton.focus();
+ },
+ setUser(user) {
+ elements.profileName.textContent = user.name || user.email;
+ if (user.picture) {
+ elements.profileImage.src = user.picture;
+ elements.profileImage.alt = `${user.name || user.email} 프로필`;
+ elements.profileImage.hidden = false;
+ }
+ elements.drawerUserName.textContent = user.name || user.email;
+ elements.drawerUserEmail.textContent = user.email;
+ elements.manageAccessButton.hidden = !user.canManageAccess;
+ },
+ };
+}
diff --git a/public/js/app.js b/public/js/app.js
index 153ee8b..72b92e3 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -1,8 +1,11 @@
import { apiRequest } from './api.js';
+import { mountAppBar } from './app-bar.js';
import { bindLogout, loadSession } from './auth.js';
import { searchRecipes } from './recipe-search.js';
import { bindScrollToTop } from './scroll-to-top.js';
+const appBar = mountAppBar({ onManageAccess: openAccessManager });
+
const elements = {
importForm: document.querySelector('#import-form'),
importStatus: document.querySelector('#import-status'),
@@ -17,13 +20,6 @@ const elements = {
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'),
@@ -199,20 +195,7 @@ async function loadAllowedEmails() {
}
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();
+ document.body.classList.toggle('drawer-open', !elements.accessOverlay.hidden);
}
function setAccessOverlayOpen(open) {
@@ -221,7 +204,16 @@ function setAccessOverlayOpen(open) {
elements.accessOverlayBackdrop.hidden = !open;
updatePageLock();
if (open) elements.accessEmail.focus();
- else elements.accessMenuButton.focus();
+ else appBar.focusMenuButton();
+}
+
+async function openAccessManager() {
+ setAccessOverlayOpen(true);
+ try {
+ await loadAllowedEmails();
+ } catch (error) {
+ showToast(error.message);
+ }
}
function ingredientGroupTemplate(group, groupIndex) {
@@ -444,28 +436,11 @@ elements.clearRecipeSearch.addEventListener('click', () => {
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) => {
@@ -507,9 +482,11 @@ elements.allowedEmailList.addEventListener('click', async (event) => {
bindLogout();
bindScrollToTop(elements.scrollToTop);
currentUser = await loadSession();
-elements.drawerUserName.textContent = currentUser.name || currentUser.email;
-elements.drawerUserEmail.textContent = currentUser.email;
+appBar.setUser(currentUser);
await loadRecipes();
-if (currentUser.canManageAccess) {
- elements.openAccessOverlay.hidden = false;
+if (
+ currentUser.canManageAccess
+ && new URLSearchParams(window.location.search).get('manage-access') === '1'
+) {
+ await openAccessManager();
}
diff --git a/public/js/login.js b/public/js/login.js
new file mode 100644
index 0000000..92cf713
--- /dev/null
+++ b/public/js/login.js
@@ -0,0 +1,7 @@
+const error = new URLSearchParams(window.location.search).get('error');
+
+if (error === 'account-not-allowed') {
+ document.querySelector('#login-alert').hidden = false;
+ document.querySelector('#google-login').href = '/auth/google/select-account';
+ document.querySelector('#google-login-label').textContent = '다른 Google 계정으로 다시 시도';
+}
diff --git a/public/js/recipe.js b/public/js/recipe.js
index d5cb136..872a5a9 100644
--- a/public/js/recipe.js
+++ b/public/js/recipe.js
@@ -1,7 +1,9 @@
import { apiRequest } from './api.js';
+import { mountAppBar } from './app-bar.js';
import { bindLogout, loadSession } from './auth.js';
import { bindScrollToTop } from './scroll-to-top.js';
+const appBar = mountAppBar();
const detail = document.querySelector('#recipe-detail');
const toast = document.querySelector('#toast');
const scrollToTop = document.querySelector('#scroll-to-top');
@@ -221,7 +223,7 @@ function renderRecipe(recipe) {
}
bindLogout();
-await loadSession();
+appBar.setUser(await loadSession());
try {
const recipeId = decodeURIComponent(window.location.pathname.split('/').filter(Boolean).pop());
diff --git a/public/login.html b/public/login.html
index 5e7c3af..efd2a1d 100644
--- a/public/login.html
+++ b/public/login.html
@@ -13,12 +13,16 @@
PRIVATE RECIPE ARCHIVE
Our Recipe Atlas
Instagram과 YouTube에서 발견한 레시피를 한곳에 정리하세요.
-
+
+
이 Google 계정은 사용할 수 없어요.
+
관리자에게 계정 등록을 요청하거나, 허용된 다른 계정으로 다시 시도해 주세요.
+
+
G
- Google 계정으로 계속하기
+ Google 계정으로 계속하기
허용 목록에 등록된 Google 계정만 로그인할 수 있습니다.
+