commit 7b11ba0a8d1e37d51aa95b9022806d95ea74c97e Author: seung6lee Date: Wed Mar 4 21:41:09 2026 +0900 class 2-5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9b1ee42 --- /dev/null +++ b/.gitignore @@ -0,0 +1,175 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Caches + +.cache + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..5a938ce --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "tabWidth": 4, + "useTabs": false +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..97e0789 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# twofive + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run server.js +``` + +This project was created using `bun init` in bun v1.0.26. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..80f2999 Binary files /dev/null and b/bun.lockb differ diff --git a/client_secret.json b/client_secret.json new file mode 100644 index 0000000..6041ae4 --- /dev/null +++ b/client_secret.json @@ -0,0 +1,12 @@ +{ + "web": { + "client_id": "769538006975-q773s454k2l1r4q35vat4psbjfgg59pk.apps.googleusercontent.com", + "project_id": "g-cal-task-management25", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_secret": "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", + "redirect_uris": ["http://localhost:4000/callback"], + "javascript_origins": ["http://localhost"] + } +} diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..95c20ce --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + /* Linting */ + "skipLibCheck": true, + "strict": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1834383 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "twofive", + "module": "server.js", + "type": "module", + "devDependencies": { + "@types/bun": "latest", + "sass": "^1.85.1" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "dependencies": { + "express": "^4.21.2", + "open": "^10.1.0" + }, + "scripts": { + "sass-watch": "sass --watch ./public/style.scss ./public/style.css" + } +} \ No newline at end of file diff --git a/public/images/calendar.png b/public/images/calendar.png new file mode 100644 index 0000000..9fe0700 Binary files /dev/null and b/public/images/calendar.png differ diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..7aea74e --- /dev/null +++ b/public/index.html @@ -0,0 +1,47 @@ + + + + + + Class 2-5 + + + + + + + +
+
+ + +
+ +
+ + diff --git a/public/script.js b/public/script.js new file mode 100644 index 0000000..c5a7035 --- /dev/null +++ b/public/script.js @@ -0,0 +1,433 @@ +class meal_list extends HTMLElement { + constructor() { + super(); + + const now = new Date(); + const hours = now.getHours(); + + this.date = new Date("2025-03-10") + .toISOString() + .slice(0, 10) + .replace(/-/g, ""); + + switch (true) { + case hours < 8: + this.selectedType = 1; + break; + case hours < 14: + this.selectedType = 2; + break; + default: + this.selectedType = 3; + } + } + + connectedCallback() { + this.render(); + } + + async get_meal(date, type) { + try { + let response = await fetch(`/api/meal?date=${date}&type=${type}`); + + if (!response.ok) throw new Error("Failed to fetch meal data"); + + let data = await response.json(); + + return data; + } catch (error) { + console.error("❌ API Error:", error); + return ["식단 정보를 불러올 수 없습니다."]; + } + } + + async render() { + const header = document.createElement("header"); + + const select = document.createElement("select"); + const types = ["아침", "점심", "저녁"]; + types.forEach((type, index) => { + const option = document.createElement("option"); + option.textContent = types[index]; + option.value = index + 1; + if (index + 1 === this.selectedType) option.selected = true; + select.appendChild(option); + }); + select.addEventListener("change", async () => { + this.selectedType = Number(select.value); + await this.render(); + }); + + const res = await this.get_meal(this.date, this.selectedType); + + const ul = document.createElement("ul"); + res.meal_list.forEach((meal) => { + const li = document.createElement("li"); + li.textContent = meal; + ul.appendChild(li); + }); + + const kcal = document.createElement("p"); + kcal.textContent = res.kcal; + + header.appendChild(select); + header.appendChild(kcal); + + this.innerHTML = ""; + this.append(header); + this.appendChild(ul); + } +} + +customElements.define("meal-list", meal_list); + +class task_list extends HTMLElement { + constructor() { + super(); + } + + connectedCallback() { + this.render(); + } + + async render() { + const ul = document.createElement("ul"); + + let data = await fetch("/api/tasklist"); + data = await data.json(); + + for (let i of data) { + const date = new Date(i.end.date); + date.setDate(date.getDate() - 1); + + const li = document.createElement("li"); + + const name = document.createElement("h2"); + name.textContent = i.summary; + + const box = document.createElement("div"); + const cal = document.createElement("img"); + cal.src = "/public/images/calendar.png"; + const deadline = document.createElement("p"); + deadline.textContent = date.toISOString().slice(0, 10); + + box.append(cal, deadline); + + li.append(name, box); + + li.addEventListener("click", () => { + const modal = document.createElement("task-edit"); + modal.setAttribute("event_id", i.id); + document.body.appendChild(modal); + }); + + ul.appendChild(li); + } + this.innerHTML = ""; + this.appendChild(ul); + } +} + +customElements.define("task-list", task_list); + +class edit_task extends HTMLElement { + constructor() { + super(); + } + + connectedCallback() { + this.render(); + } + + async getData() { + const response = await fetch( + `/api/gettask?event_id=${this.getAttribute("event_id")}` + ); + + let data = response.json(); + + return data; + } + + async render() { + this.className = "task_modal"; + + const data = await this.getData(); + + // 📌 닫기 버튼 + const closeBtn = document.createElement("span"); + closeBtn.classList.add("close"); + closeBtn.innerHTML = "✖"; + closeBtn.addEventListener("click", () => this.remove()); + + // 📌 제목 + const title = document.createElement("div"); + title.classList.add("title"); + title.textContent = "Edit Task"; + + // 📌 Name 입력란 + const nameGroup = this.createInputGroup( + "Name", + "text", + "name", + data.summary + ); + + // 📌 Date 입력란 (시작~종료) + const dateGroup = document.createElement("div"); + dateGroup.classList.add("input-group"); + const dateLabel = document.createElement("label"); + dateLabel.textContent = "Date"; + const dateContainer = document.createElement("div"); + dateContainer.classList.add("date-group"); + const startDate = this.createInput( + "date", + "start-date", + data.start.date + ); + const tilde = document.createElement("span"); + tilde.textContent = "~"; + const endDate = this.createInput("date", "end-date", data.end.date); + dateContainer.append(startDate, tilde, endDate); + dateGroup.append(dateLabel, dateContainer); + + // 📌 Description 입력란 + const descGroup = this.createInputGroup( + "Desc", + "textarea", + "desc", + data.description + ); + + // 📌 버튼 컨테이너 + const buttonContainer = document.createElement("div"); + buttonContainer.classList.add("buttons"); + + const cancelBtn = document.createElement("button"); + cancelBtn.classList.add("cancel"); + cancelBtn.textContent = "Cancel"; + cancelBtn.addEventListener("click", () => this.remove()); + + const saveBtn = document.createElement("button"); + saveBtn.classList.add("save"); + saveBtn.textContent = "Save"; + saveBtn.addEventListener("click", async () => await this.saveTask()); + + buttonContainer.append(cancelBtn, saveBtn); + + this.innerHTML = ""; + this.append( + closeBtn, + title, + nameGroup, + dateGroup, + descGroup, + buttonContainer + ); + } + + createInputGroup(labelText, inputType, id, default_value) { + const group = document.createElement("div"); + group.classList.add("input-group"); + + const label = document.createElement("label"); + label.textContent = labelText; + + let input; + if (inputType === "textarea") { + input = document.createElement("textarea"); + input.rows = "4"; + } else { + input = document.createElement("input"); + input.type = inputType; + } + input.value = default_value; + input.placeholder = default_value; + input.id = id; + + group.append(label, input); + return group; + } + + createInput(type, id, default_value) { + const input = document.createElement("input"); + input.type = type; + input.id = id; + input.value = default_value; + input.placeholder = default_value; + return input; + } + + async saveTask() { + const name = document.getElementById("name").value; + const startDate = document.getElementById("start-date").value; + const endDate = document.getElementById("end-date").value; + const desc = document.getElementById("desc").value; + + let response = await fetch("/api/edittask", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + event_id: this.getAttribute("event_id"), + data: { + end: { date: endDate }, + start: { date: startDate }, + summary: name, + description: desc, + }, + }), + }); + response = await response.json(); + console.log(response); + + const taskListComponent = document.querySelector("task-list"); + if (taskListComponent) { + taskListComponent.render(); + } + + const targetIframe = document.getElementById("calendar_iframe"); + if (targetIframe) { + targetIframe.src = targetIframe.src; // ✅ iframe 새로고침 + } + + this.remove(); + } +} + +customElements.define("task-edit", edit_task); + +class create_task extends HTMLElement { + constructor() { + super(); + } + + connectedCallback() { + this.render(); + } + + async render() { + this.className = "task_modal"; + + // 📌 닫기 버튼 + const closeBtn = document.createElement("span"); + closeBtn.classList.add("close"); + closeBtn.innerHTML = "✖"; + closeBtn.addEventListener("click", () => this.remove()); + + // 📌 제목 + const title = document.createElement("div"); + title.classList.add("title"); + title.textContent = "Create Task"; + + // 📌 Name 입력란 + const nameGroup = this.createInputGroup("Name", "text", "name"); + + // 📌 Date 입력란 (시작~종료) + const dateGroup = document.createElement("div"); + dateGroup.classList.add("input-group"); + const dateLabel = document.createElement("label"); + dateLabel.textContent = "Date"; + const dateContainer = document.createElement("div"); + dateContainer.classList.add("date-group"); + const startDate = this.createInput("date", "start-date"); + const tilde = document.createElement("span"); + tilde.textContent = "~"; + const endDate = this.createInput("date", "end-date"); + dateContainer.append(startDate, tilde, endDate); + dateGroup.append(dateLabel, dateContainer); + + // 📌 Description 입력란 + const descGroup = this.createInputGroup("Desc", "textarea", "desc"); + + // 📌 버튼 컨테이너 + const buttonContainer = document.createElement("div"); + buttonContainer.classList.add("buttons"); + + const cancelBtn = document.createElement("button"); + cancelBtn.classList.add("cancel"); + cancelBtn.textContent = "Cancel"; + cancelBtn.addEventListener("click", () => this.remove()); + + const saveBtn = document.createElement("button"); + saveBtn.classList.add("save"); + saveBtn.textContent = "Save"; + saveBtn.addEventListener("click", async () => await this.saveTask()); + + buttonContainer.append(cancelBtn, saveBtn); + + this.innerHTML = ""; + this.append( + closeBtn, + title, + nameGroup, + dateGroup, + descGroup, + buttonContainer + ); + } + + createInputGroup(labelText, inputType, id) { + const group = document.createElement("div"); + group.classList.add("input-group"); + + const label = document.createElement("label"); + label.textContent = labelText; + + let input; + if (inputType === "textarea") { + input = document.createElement("textarea"); + input.rows = "4"; + } else { + input = document.createElement("input"); + input.type = inputType; + } + input.id = id; + + group.append(label, input); + return group; + } + + createInput(type, id) { + const input = document.createElement("input"); + input.type = type; + input.id = id; + return input; + } + + async saveTask() { + const name = document.getElementById("name").value; + const startDate = document.getElementById("start-date").value; + const endDate = document.getElementById("end-date").value; + const desc = document.getElementById("desc").value; + + let response = await fetch("/api/createtask", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + event_id: this.getAttribute("event_id"), + data: { + end: { date: endDate }, + start: { date: startDate }, + summary: name, + description: desc, + }, + }), + }); + response = await response.json(); + console.log(response); + + const taskListComponent = document.querySelector("task-list"); + if (taskListComponent) { + taskListComponent.render(); + } + + const targetIframe = document.getElementById("calendar_iframe"); + if (targetIframe) { + targetIframe.src = targetIframe.src; // ✅ iframe 새로고침 + } + + this.remove(); + } +} + +customElements.define("create-task", create_task); diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..1794779 --- /dev/null +++ b/public/style.css @@ -0,0 +1,304 @@ +@import url("https://fonts.googleapis.com/css2?family=Fira+Code:wght@300..700&display=swap"); +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + width: 100%; + height: 100vh; + font-family: "Fira Code", serif; + display: flex; + flex-direction: column; + gap: 10px; + background-color: rgb(246, 242, 237); +} + +.box { + border-radius: 10px; + position: relative; +} + +.title { + height: 30px; + width: 100%; + border-bottom: 2px solid black; + margin-bottom: 10px; + font-size: 20px; + font-weight: 700; + display: flex; + align-items: center; +} + +#header { + width: 100%; + height: 60px; + background-color: white; + box-shadow: 0 1px 1px white; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + padding-left: 20px; + padding-right: 20px; +} +#header h1 { + font-size: 20px; +} +#header ul { + display: flex; + flex-direction: row; +} +#header ul li { + display: inline-block; + padding: 0 2rem; + font-size: 20px; + font-weight: 700; +} +#header ul li a { + text-decoration: none; + color: black; +} + +#main { + width: 100%; + height: calc(100% - 60px - 10px); + padding: 10px; + display: flex; + flex-direction: row; + gap: 25px; +} + +#side { + width: 400px; + height: 100%; + display: flex; + flex-direction: column; + gap: 25px; +} + +#task { + width: 100%; + height: calc(100% - 515px - 25px); + background-color: white; + border: none; + box-shadow: 0 5px 10px #d4d4d4; + padding: 10px; + display: flex; + flex-direction: column; +} +#task task-list { + width: 100%; + flex-grow: 1; +} +#task task-list ul { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; +} +#task task-list ul li { + width: 100%; + height: 65px; + border-radius: 10px; + border: 2px solid #b4b4b4; + display: flex; + flex-direction: column; + justify-content: center; + gap: 2px; + padding-left: 10px; + cursor: pointer; +} +#task task-list ul li:hover { + border: 2px solid black; +} +#task task-list ul li h2 { + font-size: 18px; + font-size: 700; +} +#task task-list ul li div { + display: flex; + flex-direction: row; + align-items: center; + gap: 5px; +} +#task task-list ul li div img { + width: 15px; + height: 15px; +} +#task task-list ul li div p { + font-size: 15px; + font-weight: 700; + color: #fbb40e; +} +#task #add_task { + cursor: pointer; + font-size: 25px; + padding-right: 10px; + display: flex; + flex-direction: row-reverse; +} + +#info { + width: 100%; + height: 515px; + background-color: white; + border: none; + box-shadow: 0 5px 10px #d4d4d4; + padding: 10px; +} +#info meal-list { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + gap: 10px; + overflow-y: none; +} +#info meal-list header { + width: 100%; + height: 40px; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; +} +#info meal-list header select { + border: none; + border-radius: 5px; + padding: 4px 4px; + font-size: 15px; + cursor: pointer; +} +#info meal-list header select:hover { + box-shadow: 0 0 0 1px black; +} +#info meal-list ul { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + overflow-y: auto; + overflow-x: hidden; + cursor: ns-resize; +} +#info meal-list ul::-webkit-scrollbar { + display: none; +} +#info meal-list ul li { + width: 100%; + height: 60px; + border-radius: 10px; + border: 2px solid #b4b4b4; + display: flex; + flex-direction: row; + align-items: center; + padding-left: 10px; + flex-shrink: 0; +} + +#calendar { + border: 2px solid orange; + width: calc(100% - 400px - 25px); + height: 100%; +} +#calendar iframe { + border-radius: 8px; + width: 100%; + height: 100%; + border: none; +} + +.task_modal { + width: 400px; + height: auto; + border: 2px solid black; + border-radius: 10px; + background: white; + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + padding: 10px; + display: flex; + flex-direction: column; + gap: 10px; +} +.task_modal label { + font-weight: 700; + display: block; + margin-bottom: 5px; +} +.task_modal input, +.task_modal textarea { + width: 100%; + padding: 8px; + border: 1px solid black; + border-radius: 4px; +} +.task_modal textarea { + height: 100px; + resize: none; +} +.task_modal .date-group { + display: flex; + align-items: center; + gap: 10px; +} +.task_modal .buttons { + display: flex; + justify-content: flex-end; + gap: 10px; +} +.task_modal button { + padding: 8px 12px; + border-radius: 5px; + cursor: pointer; + font-size: 14px; + font-weight: 700; +} +.task_modal .cancel { + border: 1px solid black; + background: white; +} +.task_modal .save { + background: #d1d1d1; + border: none; +} +.task_modal .close { + position: absolute; + top: 10px; + right: 10px; + cursor: pointer; + font-size: 18px; +} + +@media (max-width: 800px) { + h1 { + font-size: 20px; + } + #main { + width: 100%; + flex-direction: column; + } + #side { + width: 100%; + height: auto; + } + #task { + height: auto; + } + #info { + height: auto; + } + #calendar { + width: 100%; + height: 400px; + flex-shrink: 0; + } +} + +/*# sourceMappingURL=style.css.map */ diff --git a/public/style.css.map b/public/style.css.map new file mode 100644 index 0000000..fc8b9aa --- /dev/null +++ b/public/style.css.map @@ -0,0 +1 @@ +{"version":3,"sourceRoot":"","sources":["style.scss"],"names":[],"mappings":"AAAQ;AAQR;EACI;EACA;EACA;;;AAGJ;EACI;EACA;EACA;EAEA;EACA;EACA,KAnBW;EAqBX;;;AAGJ;EACI;EACA;;;AAGJ;EACI;EAOA;EACA;EAEA;EAEA;EACA;EAEA;EACA;;;AAGJ;EACI;EACA,QA/CY;EAgDZ;EACA;EAEA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;;AAGJ;EACI;EACA;;AAEA;EACI;EACA;EACA;EACA;;AAEA;EACI;EACA;;;AAMhB;EACI;EACA;EAEA,SAxFW;EA0FX;EACA;EAEA,KA5FS;;;AA+Fb;EACI,OA/FQ;EAgGR;EAEA;EACA;EAEA,KAtGS;;;AAyGb;EACI;EACA;EAEA;EACA;EACA;EAEA,SAlHW;EAoHX;EACA;;AAEA;EACI;EACA;;AAEA;EACI;EAEA;EACA;EACA;EACA,KAjIG;;AAmIH;EACI;EACA;EACA;EACA;EAEA;EACA;EACA;EACA;EACA,cA7ID;EA+IC;;AAEA;EACI;;AAGJ;EACI;EACA;;AAGJ;EACI;EACA;EACA;EACA;;AAEA;EACI;EACA;;AAGJ;EACI;EACA;EACA;;AAOpB;EACI;EACA;EAEA;EAEA;EACA;;;AAIR;EACI;EACA,QAzLU;EA2LV;EACA;EACA;EAEA,SAlMW;;AAoMX;EACI;EACA;EAEA;EACA;EACA,KA1MO;EA4MP;;AAEA;EACI;EACA;EAEA;EACA;EACA;EACA;;AAEA;EACI;EACA;EACA;EAEA;EACA;;AAEA;EACI;;AAKZ;EACI;EAEA;EACA;EACA;EACA,KA3OG;EA6OH;EACA;EAEA;;AAEA;EACI;;AAGJ;EACI;EACA;EACA;EACA;EAEA;EACA;EACA;EACA,cA/PD;EAiQC;;;AAMhB;EACI;EAEA;EACA;;AAEA;EACI;EACA;EACA;EACA;;;AAIR;EACI;EACA;EAEA;EACA;EACA;EAEA;EACA;EACA;EACA;EAEA,SAlSW;EAoSX;EACA;EACA,KAtSW;;AAwSX;EACI;EACA;EACA;;AAGJ;AAAA;EAEI;EACA;EACA;EACA;;AAGJ;EACI;EACA;;AAGJ;EACI;EACA;EACA;;AAGJ;EACI;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;;AAGJ;EACI;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;;;AAIR;EACI;IACI;;EAGJ;IACI;IACA;;EAGJ;IACI;IACA;;EAEJ;IACI;;EAEJ;IACI;;EAGJ;IACI;IACA;IACA","file":"style.css"} \ No newline at end of file diff --git a/public/style.scss b/public/style.scss new file mode 100644 index 0000000..dbeafde --- /dev/null +++ b/public/style.scss @@ -0,0 +1,383 @@ +@import url("https://fonts.googleapis.com/css2?family=Fira+Code:wght@300..700&display=swap"); + +$small_margin: 10px; +$big_margin: 25px; +$nav_width: 400px; +$info_height: calc(60px * 7 + $small_margin * 7 + $big_margin); +$header_height: 60px; + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + width: 100%; + height: 100vh; + font-family: "Fira Code", serif; + + display: flex; + flex-direction: column; + gap: $small_margin; + + background-color: rgb(246, 242, 237); +} + +.box { + border-radius: 10px; + position: relative; +} + +.title { + height: 30px; + // background-color: white; + + // position: absolute; + // top: -2px; + // left: 15px; + + width: 100%; + border-bottom: 2px solid black; + + margin-bottom: 10px; + + font-size: 20px; + font-weight: 700; + + display: flex; + align-items: center; +} + +#header { + width: 100%; + height: $header_height; + background-color: white; + box-shadow: 0 1px 1px white; + + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + padding-left: 20px; + padding-right: 20px; + + h1 { + font-size: 20px; + } + + ul { + display: flex; + flex-direction: row; + + li { + display: inline-block; + padding: 0 2rem; + font-size: 20px; + font-weight: 700; + + a { + text-decoration: none; + color: black; + } + } + } +} + +#main { + width: 100%; + height: calc(100% - $header_height - $small_margin); + + padding: $small_margin; + + display: flex; + flex-direction: row; + + gap: $big_margin; +} + +#side { + width: $nav_width; + height: 100%; + + display: flex; + flex-direction: column; + + gap: $big_margin; +} + +#task { + width: 100%; + height: calc(100% - $info_height - $big_margin); + + background-color: white; + border: none; + box-shadow: 0 5px 10px #d4d4d4; + + padding: $small_margin; + + display: flex; + flex-direction: column; + + task-list { + width: 100%; + flex-grow: 1; + + ul { + width: 100%; + + display: flex; + flex-direction: column; + align-items: center; + gap: $small_margin; + + li { + width: 100%; + height: 65px; + border-radius: 10px; + border: 2px solid #b4b4b4; + + display: flex; + flex-direction: column; + justify-content: center; + gap: 2px; + padding-left: $small_margin; + + cursor: pointer; + + &:hover { + border: 2px solid black; + } + + h2 { + font-size: 18px; + font-size: 700; + } + + div { + display: flex; + flex-direction: row; + align-items: center; + gap: 5px; + + img { + width: 15px; + height: 15px; + } + + p { + font-size: 15px; + font-weight: 700; + color: #fbb40e; + } + } + } + } + } + + #add_task { + cursor: pointer; + font-size: 25px; + + padding-right: 10px; + + display: flex; + flex-direction: row-reverse; + } +} + +#info { + width: 100%; + height: $info_height; + + background-color: white; + border: none; + box-shadow: 0 5px 10px #d4d4d4; + + padding: $small_margin; + + meal-list { + width: 100%; + height: 100%; + + display: flex; + flex-direction: column; + gap: $small_margin; + + overflow-y: none; + + header { + width: 100%; + height: 40px; + + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + + select { + border: none; + border-radius: 5px; + padding: 4px 4px; + + font-size: 15px; + cursor: pointer; + + &:hover { + box-shadow: 0 0 0 1px black; + } + } + } + + ul { + width: 100%; + + display: flex; + flex-direction: column; + align-items: center; + gap: $small_margin; + + overflow-y: auto; + overflow-x: hidden; + + cursor: ns-resize; + + &::-webkit-scrollbar { + display: none; + } + + li { + width: 100%; + height: 60px; + border-radius: 10px; + border: 2px solid #b4b4b4; + + display: flex; + flex-direction: row; + align-items: center; + padding-left: $small_margin; + + flex-shrink: 0; + } + } + } +} + +#calendar { + border: 2px solid orange; + + width: calc(100% - $nav_width - $big_margin); + height: 100%; + + iframe { + border-radius: 8px; + width: 100%; + height: 100%; + border: none; + } +} + +.task_modal { + width: 400px; + height: auto; + + border: 2px solid black; + border-radius: 10px; + background: white; + + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + + padding: $small_margin; + + display: flex; + flex-direction: column; + gap: $small_margin; + + label { + font-weight: 700; + display: block; + margin-bottom: 5px; + } + + input, + textarea { + width: 100%; + padding: 8px; + border: 1px solid black; + border-radius: 4px; + } + + textarea { + height: 100px; + resize: none; + } + + .date-group { + display: flex; + align-items: center; + gap: 10px; + } + + .buttons { + display: flex; + justify-content: flex-end; + gap: 10px; + } + + button { + padding: 8px 12px; + border-radius: 5px; + cursor: pointer; + font-size: 14px; + font-weight: 700; + } + + .cancel { + border: 1px solid black; + background: white; + } + + .save { + background: #d1d1d1; + border: none; + } + + .close { + position: absolute; + top: 10px; + right: 10px; + cursor: pointer; + font-size: 18px; + } +} + +@media (max-width: 800px) { + h1 { + font-size: 20px; + } + + #main { + width: 100%; + flex-direction: column; + } + + #side { + width: 100%; + height: auto; + } + #task { + height: auto; + } + #info { + height: auto; + } + + #calendar { + width: 100%; + height: 400px; + flex-shrink: 0; + } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..a1fed43 --- /dev/null +++ b/server.js @@ -0,0 +1,386 @@ +import express from "express"; +import path from "path"; +import open from "open"; +import fs from "fs"; + +const app = express(); + +app.use("/public", express.static(path.join(__dirname, "public"))); +app.use(express.urlencoded({ extends: true })); +app.use(express.json()); + +app.listen(4000, () => { + console.log("✅Pass: Server running at http://127.0.0.1:4000"); +}); + +app.get("/", (req, res) => { + res.sendFile(__dirname + "/public/index.html"); +}); + +app.get("/api/meal", async (req, res) => { + const item = req.query; + const date = item.date; + const type = item.type; + + const base_url = new URL("https://open.neis.go.kr/hub/mealServiceDietInfo"); + const params = new URLSearchParams({ + key: "504b37988adb4251b9fae916ae8c360a", + Type: "json", + ATPT_OFCDC_SC_CODE: "D10", + SD_SCHUL_CODE: "7240450", + MLSV_YMD: date, + MMEAL_SC_CODE: type, + }); + const url = `${base_url}?${params.toString()}`; + + try { + let data = await fetch(url); + + if (!data.ok) { + res.send("No Information"); + } + + data = await data.json(); + + if (!data.mealServiceDietInfo[1].row[0]) { + res.send("No Information"); + } + + let meal_list = data.mealServiceDietInfo[1].row[0].DDISH_NM; + meal_list = meal_list.replace(/\([^)]+\)/g, "").split("
"); + + res.json({ + meal_list: meal_list, + kcal: data.mealServiceDietInfo[1].row[0].CAL_INFO, + }); + } catch (error) { + res.send("No Information"); + } +}); + +// G-Cal api ------------------------------------------------------------ + +const checkClientSecret = () => { + if (!fs.existsSync("client_secret.json")) { + throw new Error("No client_secret.json"); + } + console.log("✅Pass: client_secret.json exists"); +}; + +const checkToken = () => { + if (!fs.existsSync("token.json")) { + console.error("❌Error: No token.json"); + console.warn("📣Notice: Trying to get token"); + getToken(); + } else { + console.log("✅Pass: token.json exists"); + } +}; + +const getToken = () => { + const base_url = new URL("https://accounts.google.com/o/oauth2/auth"); + const params = new URLSearchParams({ + client_id: client().client_id, + redirect_uri: "http://localhost:4000/callback", + response_type: "code", + scope: "https://www.googleapis.com/auth/calendar", + access_type: "offline", + prompt: "consent", + }); + const url = `${base_url}?${params.toString()}`; + open(url); +}; + +const client = () => { + const fileContent = fs.readFileSync("client_secret.json", "utf8"); + return JSON.parse(fileContent).web; +}; + +const token = (type) => { + const fileContent = fs.readFileSync("token.json", "utf8"); + if (type) { + return JSON.parse(fileContent)[type]; + } + return JSON.parse(fileContent).access_token; +}; + +app.get("/callback", async (req, res) => { + try { + const response = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: {}, + body: JSON.stringify({ + client_id: client().client_id, + client_secret: client().client_secret, + code: req.query.code, + grant_type: "authorization_code", + redirect_uri: "http://localhost:4000/callback", + }), + }); + + const data = await response.json(); + + if (data.error) { + console.error(`❌Error: ${data.error}, ${data.error_description}`); + res.send( + "Error occured. Automatically reopen new Oauth page if token is not saved." + ); + checkToken(); + return; + } + + console.log("✅Pass: Successfully load token"); + saveToken(data); + res.json(data); + } catch (err) { + console.error(`❌Error: ${err.message}`); + res.status(500).json({ error: err.message }); + } +}); + +const saveToken = (data) => { + fs.writeFileSync("token.json", JSON.stringify(data, null, 2)); + console.log("✅Pass: Successfully sace token.json"); +}; + +const refreshToken = async () => { + try { + const response = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: {}, + body: JSON.stringify({ + client_id: client().client_id, + client_secret: client().client_secret, + grant_type: "refresh_token", + refresh_token: token("refresh_token"), + }), + }); + + const data = await response.json(); + + if (data.error) { + return data; + } + + data.refresh_token = token("refresh_token"); + saveToken(data); + return "success"; + } catch (err) { + return err; + } +}; + +checkClientSecret(); +checkToken(); + +app.get("/api/tasklist", async (req, res) => { + const process = async () => { + const task_calender_id = + "729f5bea9ba83ff9b82dd797f15089a051fe850a4da66fcc3936835e89e3de89@group.calendar.google.com"; + const base_url = new URL( + `https://www.googleapis.com/calendar/v3/calendars/${task_calender_id}/events` + ); + const params = new URLSearchParams({ + singleEvents: true, + orderBy: "startTime", + timeMin: new Date().toISOString(), + }); + const url = `${base_url}?${params.toString()}`; + + try { + let data = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${token()}`, + }, + }); + + data = await data.json(); + + if (data.error) { + if (data.error.code === 401) { + console.warn("📣Notice: Token expired, refreshing..."); + + const refreshStatus = await refreshToken(); + if (refreshStatus === "success") { + console.warn("📣Notice: Token refresh success"); + return await process(); + } else { + console.error("❌Error: Can't refresh token"); + return refreshStatus; + } + } + + console.error( + `❌Error: ${data.error.code}, ${data.error.message}` + ); + + return data; + } + + const li = data.items; + + res.json(li); + } catch (err) { + console.error(`❌Error: ${err}`); + return err; + } + }; + + return await process(); +}); + +app.get("/api/gettask", async (req, res) => { + const process = async () => { + const task_calender_id = + "729f5bea9ba83ff9b82dd797f15089a051fe850a4da66fcc3936835e89e3de89@group.calendar.google.com"; + const base_url = new URL( + `https://www.googleapis.com/calendar/v3/calendars/${task_calender_id}/events/${req.query.event_id}` + ); + const params = new URLSearchParams({ + singleEvents: true, + orderBy: "startTime", + timeMin: new Date().toISOString(), + }); + const url = `${base_url}?${params.toString()}`; + + try { + let data = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${token()}`, + }, + }); + + data = await data.json(); + + if (data.error) { + if (data.error.code === 401) { + console.warn("📣Notice: Token expired, refreshing..."); + + const refreshStatus = await refreshToken(); + if (refreshStatus === "success") { + console.warn("📣Notice: Token refresh success"); + return await process(); + } else { + console.error("❌Error: Can't refresh token"); + return refreshStatus; + } + } + + console.error( + `❌Error: ${data.error.code}, ${data.error.message}` + ); + + return data; + } + + res.json(data); + } catch (err) { + console.error(`❌Error: ${err}`); + return err; + } + }; + + return await process(); +}); + +app.post("/api/edittask", async (req, res) => { + const process = async () => { + const task_calender_id = + "729f5bea9ba83ff9b82dd797f15089a051fe850a4da66fcc3936835e89e3de89@group.calendar.google.com"; + const url = new URL( + `https://www.googleapis.com/calendar/v3/calendars/${task_calender_id}/events/${req.body.event_id}` + ); + + try { + let data = await fetch(url, { + method: "PUT", + headers: { + Authorization: `Bearer ${token()}`, + }, + body: JSON.stringify(req.body.data), + }); + + data = await data.json(); + + if (data.error) { + if (data.error.code === 401) { + console.warn("📣Notice: Token expired, refreshing..."); + + const refreshStatus = await refreshToken(); + if (refreshStatus === "success") { + console.warn("📣Notice: Token refresh success"); + return await process(); + } else { + console.error("❌Error: Can't refresh token"); + return refreshStatus; + } + } + + console.error( + `❌Error: ${data.error.code}, ${data.error.message}` + ); + + return data; + } + + res.json(data); + } catch (err) { + console.error(`❌Error: ${err}`); + return err; + } + }; + + return await process(); +}); + +app.post("/api/createtask", async (req, res) => { + const process = async () => { + const task_calender_id = + "729f5bea9ba83ff9b82dd797f15089a051fe850a4da66fcc3936835e89e3de89@group.calendar.google.com"; + const url = new URL( + `https://www.googleapis.com/calendar/v3/calendars/${task_calender_id}/events` + ); + + try { + let data = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${token()}`, + }, + body: JSON.stringify(req.body.data), + }); + + data = await data.json(); + + if (data.error) { + if (data.error.code === 401) { + console.warn("📣Notice: Token expired, refreshing..."); + + const refreshStatus = await refreshToken(); + if (refreshStatus === "success") { + console.warn("📣Notice: Token refresh success"); + return await process(); + } else { + console.error("❌Error: Can't refresh token"); + return refreshStatus; + } + } + + console.error( + `❌Error: ${data.error.code}, ${data.error.message}` + ); + + return data; + } + + res.json(data); + } catch (err) { + console.error(`❌Error: ${err}`); + return err; + } + }; + + return await process(); +}); \ No newline at end of file diff --git a/token.json b/token.json new file mode 100644 index 0000000..1290e02 --- /dev/null +++ b/token.json @@ -0,0 +1,8 @@ +{ + "access_token": "ya29.a0AeXRPp6dQRljlR_5KWE8DgcWu4lkKXDAgErjVzNGkgIZwiCKXqk5MtqKfB2Wp_DG58SzfM2UmPJJpeIbflU-D0_xxCnD0u0En_OccmeRsSEE56KY5q_NtQtnpwUekEvtvEjhIiGfU0M5NdgljLvnAN7JkPvv-Kjd2liy59j17QaCgYKAWkSARASFQHGX2MiA14X1b8pOFBNNRtkZ3xKug0177", + "expires_in": 3599, + "scope": "https://www.googleapis.com/auth/calendar", + "token_type": "Bearer", + "refresh_token_expires_in": 571598, + "refresh_token": "1//0erCBDblHqeswCgYIARAAGA4SNwF-L9IrKNzH7uKvucIavo21bciIQe04jXA3w-aHCLvrUmUNoaIVNn1wx4fapAmpifxS-kjcPyk" +} \ No newline at end of file