PageSourceSearch

https://synspear.com/js/blog_script.js

js synspear.com collected 2026-10-02 01:45:05 UTC 15,226 bytes, 499 lines download raw bytes

1// 메인 메뉴 드릴다운 — Services / Plugin 하위 메뉴
2function initMenuDrill() {
3  const menuNav = document.getElementById("menu-nav");
4  const drillBtns = document.querySelectorAll(
5    ".menu-drill[data-menu-target]"
6  );
7  const backBtns = document.querySelectorAll(".menu-back");
8  const subPanels = document.querySelectorAll(".menu-sub-panel");
9
10  if (!menuNav || !drillBtns.length || !backBtns.length) {
11    return null;
12  }
13
14  function setActivePanel(panelId) {
15    subPanels.forEach(function (panel) {
16      panel.classList.toggle("is-active", panel.id === panelId);
17    });
18  }
19
20  function openSub(panelId, drillBtn) {
21    setActivePanel(panelId);
22    menuNav.classList.add("is-sub-open");
23    drillBtns.forEach(function (btn) {
24      btn.setAttribute(
25        "aria-expanded",
26        btn === drillBtn ? "true" : "false"
27      );
28    });
29  }
30
31  function closeSub() {
32    menuNav.classList.remove("is-sub-open");
33    drillBtns.forEach(function (btn) {
34      btn.setAttribute("aria-expanded", "false");
35    });
36  }
37
38  drillBtns.forEach(function (btn) {
39    btn.addEventListener("click", function () {
40      openSub(btn.getAttribute("data-menu-target"), btn);
41    });
42  });
43  backBtns.forEach(function (btn) {
44    btn.addEventListener("click", closeSub);
45  });
46
47  return { updateViewportWidth: function () {} };
48}
49
50// 타이핑 효과 함수
51function typeWriter(
52  element,
53  text,
54  minSpeed = 80,
55  maxSpeed = 200,
56  onComplete = null
57) {
58  let i = 0;
59
60  // 먼저 커서 추가
61  const cursor = document.createElement("span");
62  cursor.className = "cursor";
63  element.appendChild(cursor);
64
65  function type() {
66    if (i < text.length) {
67      // 현재 커서 위치에 글자 삽입
68      const textNode = document.createTextNode(text.charAt(i));
69      element.insertBefore(textNode, cursor);
70
71      const currentChar = text.charAt(i);
72      i++;
73
74      // 사람처럼 자연스러운 타이핑 속도 계산
75      let delay = Math.random() * (maxSpeed - minSpeed) + minSpeed;
76
77      // 10% 확률로 짧은 정지 (생각하는 시간)
78      if (Math.random() < 0.1) {
79        delay += Math.random() * 200 + 100; // 100~300ms 추가
80      }
81
82      // 공백이나 구두점 뒤에서는 약간 더 긴 정지
83      if (currentChar === " " || currentChar === "," || currentChar === ".") {
84        delay += Math.random() * 100 + 50; // 50~150ms 추가
85      }
86
87      // 때때로 빠르게 연속으로 타이핑하는 효과 (30% 확률)
88      if (Math.random() < 0.3) {
89        delay *= 0.5; // 절반 속도로 빠르게
90      }
91
92      setTimeout(type, delay);
93    } else {
94      // 타이핑 완료 시 콜백 함수 실행
95      if (onComplete) {
96        onComplete();
97      }
98    }
99  }
100
101  // 기존 텍스트 초기화 (커서는 유지)
102  element.innerHTML = "";
103  element.appendChild(cursor);
104  type();
105}
106
107// 백스페이스 효과 함수 (특정 텍스트만 삭제)
108function deleteText(element, deleteCount, speed = 80, onComplete = null) {
109  let i = 0;
110  const cursor = element.querySelector(".cursor");
111
112  function deleteChar() {
113    if (i < deleteCount) {
114      // 커서 바로 앞의 텍스트 노드 찾아서 삭제
115      const nodes = Array.from(element.childNodes).filter(
116        (node) =>
117          node.nodeType === Node.TEXT_NODE && node.textContent.length > 0
118      );
119      if (nodes.length > 0) {
120        const lastTextNode = nodes[nodes.length - 1];
121        if (lastTextNode.textContent.length > 1) {
122          lastTextNode.textContent = lastTextNode.textContent.slice(0, -1);
123        } else {
124          element.removeChild(lastTextNode);
125        }
126      }
127      i++;
128
129      // 백스페이스도 자연스럽게 속도 변화
130      let delay = speed + Math.random() * 40 - 20; // ±20ms 변동
131
132      // 10% 확률로 약간 더 긴 정지
133      if (Math.random() < 0.1) {
134        delay += Math.random() * 100 + 50;
135      }
136
137      // 가끔 빠르게 연속 삭제 (20% 확률)
138      if (Math.random() < 0.2) {
139        delay *= 0.6;
140      }
141
142      setTimeout(deleteChar, delay);
143    } else {
144      // 삭제 완료 시 콜백 함수 실행
145      if (onComplete) {
146        onComplete();
147      }
148    }
149  }
150
151  deleteChar();
152}
153
154// 기존 텍스트에 추가로 타이핑하는 함수
155function appendTypeWriter(
156  element,
157  text,
158  minSpeed = 80,
159  maxSpeed = 200,
160  onComplete = null
161) {
162  let i = 0;
163  const cursor = element.querySelector(".cursor");
164
165  function type() {
166    if (i < text.length) {
167      const textNode = document.createTextNode(text.charAt(i));
168      element.insertBefore(textNode, cursor);
169
170      const currentChar = text.charAt(i);
171      i++;
172
173      // 사람처럼 자연스러운 타이핑 속도 계산
174      let delay = Math.random() * (maxSpeed - minSpeed) + minSpeed;
175
176      // 10% 확률로 짧은 정지 (생각하는 시간)
177      if (Math.random() < 0.1) {
178        delay += Math.random() * 200 + 100; // 100~300ms 추가
179      }
180
181      // 공백이나 구두점 뒤에서는 약간 더 긴 정지
182      if (currentChar === " " || currentChar === "," || currentChar === ".") {
183        delay += Math.random() * 100 + 50; // 50~150ms 추가
184      }
185
186      // 때때로 빠르게 연속으로 타이핑하는 효과 (30% 확률)
187      if (Math.random() < 0.3) {
188        delay *= 0.5; // 절반 속도로 빠르게
189      }
190
191      setTimeout(type, delay);
192    } else {
193      if (onComplete) {
194        onComplete();
195      }
196    }
197  }
198
199  type();
200}
201
202// 페이지 로드 완료 후 타이핑 효과 시작
203document.addEventListener("DOMContentLoaded", function () {
204  const titleElement = document.getElementById("title");
205  const titleText = "Synspear Dev Studio";
206  const menuItems = document.querySelectorAll(
207    ".menu-main a, .menu-main .menu-drill"
208  );
209  const langMenuItems = document.querySelectorAll(".menu-lang-item");
210  const contentContainer = document.querySelector(".content");
211
212  const menuDrill = initMenuDrill();
213
214  // 비디오 크로스 페이드 효과 설정
215  const video1 = document.querySelector(".video-1");
216  const video2 = document.querySelector(".video-2");
217
218  if (video1 && video2) {
219    let currentVideo = video1;
220    let nextVideo = video2;
221
222    // 현재 비디오가 끝나기 1초 전부터 크로스 페이드 시작
223    function setupVideoTransition(current, next) {
224      current.addEventListener("timeupdate", function onTimeUpdate() {
225        const timeLeft = this.duration - this.currentTime;
226
227        if (timeLeft <= 1 && timeLeft > 0 && next.paused) {
228          // 다음 비디오 시작 및 페이드 인
229          next.currentTime = 0;
230          next.play();
231          next.style.opacity = "1";
232
233          // 현재 비디오 페이드 아웃
234          current.style.opacity = "0";
235
236          // 현재 비디오가 끝나면 정지
237          current.addEventListener("ended", function onEnded() {
238            current.pause();
239            current.removeEventListener("ended", onEnded);
240          });
241
242          // 비디오 역할 교체
243          setTimeout(() => {
244            const temp = currentVideo;
245            currentVideo = nextVideo;
246            nextVideo = temp;
247            setupVideoTransition(currentVideo, nextVideo);
248          }, 1000);
249
250          // 이벤트 리스너 제거 (한 번만 실행)
251          current.removeEventListener("timeupdate", onTimeUpdate);
252        }
253      });
254    }
255
256    setupVideoTransition(currentVideo, nextVideo);
257  }
258
259  // Dev Studio와 다른 텍스트들을 순환하는 함수
260  const alternateTexts = [
261    "Developer",
262    "Engineer",
263    "Architect",
264    "Automator",
265    "Backend Lab",
266    "Logic Studio",
267  ];
268
269  let currentTextIndex = 0;
270
271  function cycleTexts(index) {
272    if (index >= alternateTexts.length) {
273      // 모든 텍스트 순환 완료 후 "Dev Studio" 복원
274      appendTypeWriter(titleElement, "Dev Studio", 50, 100, function () {
275        // 5초 대기 후 다시 처음부터 시작
276        setTimeout(() => {
277          alternateText();
278        }, 10000);
279      });
280      return;
281    }
282
283    const currentText = alternateTexts[index];
284
285    // 현재 텍스트 타이핑
286    appendTypeWriter(titleElement, currentText, 50, 100, function () {
287      // 2초 대기 후 삭제
288      setTimeout(() => {
289        deleteText(titleElement, currentText.length, 60, function () {
290          // 다음 텍스트로 진행
291          cycleTexts(index + 1);
292        });
293      }, 2000);
294    });
295  }
296
297  function alternateText() {
298    // "Dev Studio" 삭제 (10글자)
299    deleteText(titleElement, 10, 60, function () {
300      // 순환 시작
301      cycleTexts(0);
302    });
303  }
304
305  // 뒤로가기로 돌아온 경우 체크
306  const isReturning = sessionStorage.getItem("returningHome");
307
308  // 메인 메뉴 등장 애니메이션
309  menuItems.forEach((item) => {
310    item.style.opacity = "0";
311    item.style.transform = "translateY(20px)";
312    item.style.transition = "opacity 0.6s ease, transform 0.6s ease";
313    item.style.pointerEvents = "none";
314  });
315
316  langMenuItems.forEach((item) => {
317    item.addEventListener("mouseenter", function () {
318      if (!window.SiteLang) return;
319      const lang = window.SiteLang.getLang();
320      const text =
321        lang === "ko"
322          ? this.getAttribute("data-kr")
323          : this.getAttribute("data-en");
324      if (text) this.textContent = text;
325    });
326
327    item.addEventListener("mouseleave", function () {
328      const english = this.getAttribute("data-en");
329      if (english) this.textContent = english;
330    });
331  });
332
333  function resetMenuToEnglish() {
334    langMenuItems.forEach(function (item) {
335      const english = item.getAttribute("data-en");
336      if (english) item.textContent = english;
337    });
338  }
339
340  resetMenuToEnglish();
341  window.addEventListener("sitelangchange", resetMenuToEnglish);
342
343  // 뒤로가기로 돌아온 경우 애니메이션 스킵
344  if (isReturning === "true") {
345    sessionStorage.removeItem("returningHome");
346
347    // transition 없이 즉시 최종 위치로 (애니메이션 무시)
348    contentContainer.style.transition = "none";
349    contentContainer.classList.add("final-position");
350
351    // 타이틀 즉시 표시
352    const cursor = document.createElement("span");
353    cursor.className = "cursor";
354    titleElement.textContent = titleText;
355    titleElement.appendChild(cursor);
356
357    // 메뉴 즉시 표시 (transition 제거)
358    menuItems.forEach((item) => {
359      item.style.transition = "none";
360      item.style.opacity = "1";
361      item.style.transform = "translateY(0)";
362      item.style.pointerEvents = "auto";
363    });
364
365    // 다음 프레임에서 transition 복원 (이후 애니메이션을 위해)
366    requestAnimationFrame(() => {
367      contentContainer.style.transition = "";
368      menuItems.forEach((item) => {
369        item.style.transition = "opacity 0.6s ease, transform 0.6s ease";
370      });
371    });
372
373    if (menuDrill) {
374      menuDrill.updateViewportWidth();
375    }
376
377    // 바로 교체 애니메이션 시작
378    setTimeout(() => {
379      alternateText();
380    }, 5000);
381
382    return; // 이후 타이핑 애니메이션 실행 안 함
383  }
384
385  // 0.5초 지연 후 타이핑 시작
386  setTimeout(() => {
387    typeWriter(titleElement, titleText, 25, 125, function () {
388      // 타이핑 완료 후 바로 전체 콘텐츠를 최종 위치로 이동
389      setTimeout(() => {
390        contentContainer.classList.add("final-position");
391
392        // 이동 시작과 함께 서브 타이틀들이 순차적으로 나타나게
393        menuItems.forEach((item, index) => {
394          setTimeout(() => {
395            item.style.opacity = "1";
396            item.style.transform = "translateY(0)";
397
398            // 마지막 포트폴리오 메뉴 애니메이션 완료 후 클릭 활성화
399            if (index === menuItems.length - 1) {
400              setTimeout(() => {
401                menuItems.forEach((menuItem) => {
402                  menuItem.style.pointerEvents = "auto";
403                });
404                if (menuDrill) {
405                  menuDrill.updateViewportWidth();
406                }
407              }, 600);
408            }
409          }, index * 150);
410        });
411
412        // 서브 타이틀 애니메이션 완료 후 5초 대기하고 교체 애니메이션 시작
413        const lastMenuDelay = (menuItems.length - 1) * 150 + 600; // 마지막 메뉴 애니메이션 완료 시간
414        setTimeout(() => {
415          alternateText();
416        }, lastMenuDelay + 5000); // 5초 추가 대기
417      }, 100);
418    });
419  }, 500);
420
421  // 콘텍트 폼 처리
422  const contactForm = document.querySelector(".contact-form");
423  console.log("[DEBUG] Contact form found:", contactForm);
424  if (contactForm) {
425    contactForm.addEventListener("submit", async function (e) {
426      console.log("[DEBUG] Form submit event triggered");
427      e.preventDefault();
428
429      // 폼 데이터 수집
430      const formData = {
431        company_name: document.getElementById("companyName").value,
432        contact_name: document.getElementById("contactName").value,
433        email: document.getElementById("emailAddress").value,
434        phone_number: document.getElementById("phoneNumber").value,
435        project_type: document.getElementById("projectType").value,
436        budget_range: document.getElementById("budgetRange").value,
437        timeline: document.getElementById("timeline").value,
438        project_description:
439          document.getElementById("projectDescription").value,
440        type: "production_inquiry", // 제작문의
441      };
442
443      console.log("[DEBUG] Form data collected:", formData);
444
445      // 필수 필드 검증
446      if (
447        !formData.company_name ||
448        !formData.contact_name ||
449        !formData.email ||
450        !formData.project_description
451      ) {
452        alert("필수 항목을 모두 입력해주세요.");
453        return;
454      }
455
456      // 이메일 형식 검증
457      const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
458      if (!emailRegex.test(formData.email)) {
459        alert("올바른 이메일 주소를 입력해주세요.");
460        return;
461      }
462
463      // 제출 버튼 비활성화 및 로딩 표시
464      const submitButton = contactForm.querySelector('button[type="submit"]');
465      const originalText = submitButton.innerHTML;
466      submitButton.disabled = true;
467      submitButton.innerHTML =
468        '<span class="english">Sending...</span><span class="korean">전송 중...</span>';
469
470      try {
471        const response = await fetch("/api/contact-submit", {
472          method: "POST",
473          headers: {
474            "Content-Type": "application/json",
475          },
476          body: JSON.stringify(formData),
477        });
478
479        const result = await response.json();
480
481        if (result.success) {
482          alert(
483            "문의가 성공적으로 전송되었습니다. 빠른 시일 내에 연락드리겠습니다."
484          );
485          contactForm.reset(); // 폼 초기화
486        } else {
487          alert("문의 전송에 실패했습니다: " + result.message);
488        }
489      } catch (error) {
490        console.error("Error:", error);
491        alert("문의 전송 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.");
492      } finally {
493        // 제출 버튼 복원
494        submitButton.disabled = false;
495        submitButton.innerHTML = originalText;
496      }
497    });
498  }
499});

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.