diff --git a/.gitignore b/.gitignore index 583f327..0714a4b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ .env /go.mod -/go.sum \ No newline at end of file +/go.sum +logs/ +TodayOnHistory \ No newline at end of file diff --git a/main.go b/main.go new file mode 100644 index 0000000..0626f07 --- /dev/null +++ b/main.go @@ -0,0 +1,209 @@ +package main + +import ( + "database/sql" + "encoding/json" + "fmt" + "net/http" + "os" + "strconv" + "time" + + "code.akitsuki.me/AkitsukiNagi/logging" + _ "github.com/joho/godotenv/autoload" + _ "github.com/lib/pq" +) + +type Event struct { + Title string `json:"title"` + Description string `json:"description"` + Year int `json:"year"` +} + +type Response struct { + Year int `json:"year"` + Month int `json:"month"` + Day int `json:"day"` + Events []*Event `json:"events"` +} + +var ( + db *sql.DB + connStr = fmt.Sprintf( + "host=%s user=%s password=%s dbname=%s port=%s sslmode=%s", + os.Getenv("HOST"), + os.Getenv("USER"), + os.Getenv("PASSWORD"), + os.Getenv("DBNAME"), + os.Getenv("PORT"), + os.Getenv("SSLMODE"), + ) + loc *time.Location + port int + static = http.FileServer(http.Dir("./static")) +) + +func init() { + var err error + port, err = strconv.Atoi(os.Getenv("HTTP_PORT")) + if err != nil { + logging.GetLogger().Critical("Error parsing port: %v", err) + } +} + +func main() { + loc, _ = time.LoadLocation("Asia/Taipei") + + var err error + db, err = sql.Open("postgres", connStr) + if err != nil { + logging.GetLogger().Error("Failed to connect to the database: %v", err) + } + + err = db.Ping() + if err != nil { + logging.GetLogger().Error("Failed to ping to the database: %v", err) + } else { + logging.GetLogger().Info("Successfully connected to the database.") + } + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + static.ServeHTTP(w, r) + }) + + http.Handle("/static/", http.StripPrefix("/static", static)) + http.HandleFunc("/static/index.html", func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + }) + + http.HandleFunc("/api/query", serveAPI) + + logging.GetLogger().Info("Server started on localhost:%d", port) + logging.GetLogger().Error("%v", http.ListenAndServe(fmt.Sprintf(":%d", port), nil)) +} + +func serveAPI(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + var ( + resp Response + events []*Event + date time.Time + + sYear = query.Get("year") + sMonth = query.Get("month") + sDay = query.Get("day") + ) + + if sYear == "" && sMonth == "" && sDay == "" { + date = time.Now() + events = apiQuerying(date.Year(), int(date.Month()), date.Day()) + resp = Response{ + Year: date.Year(), + Month: int(date.Month()), + Day: date.Day(), + Events: events, + } + + err := json.NewEncoder(w).Encode(resp) + if err != nil { + logging.GetLogger().Error("Failed to parse data into json: %v", err) + } + return + } + + year, month, day := parseTime(sYear, sMonth, sDay) + date = time.Now() + sDate := fmt.Sprintf("%d/%d/%d", year, month, day) + date, err := time.Parse("2006/1/2", sDate) + if err != nil { + logging.GetLogger().Error("Error parsing date: %v", err) + } + + events = apiQuerying(date.Year(), int(date.Month()), date.Day()) + resp = Response{ + Year: date.Year(), + Month: int(date.Month()), + Day: date.Day(), + Events: events, + } + + err = json.NewEncoder(w).Encode(resp) + if err != nil { + logging.GetLogger().Error("Failed to parse data into json: %v", err) + } +} + +func apiQuerying(year, month, day int) []*Event { + var ( + rows *sql.Rows + err error + ) + + rows, err = db.Query("SELECT year, title, description FROM events WHERE month = $1 AND day = $2 AND YEAR <= $3 ORDER BY year", month, day, year) + if err != nil { + logging.GetLogger().Error("%v", err) + } + + var events []*Event + + defer rows.Close() + for rows.Next() { + event := &Event{} + err := rows.Scan(&event.Year, &event.Title, &event.Description) + if err != nil { + logging.GetLogger().Error("%v", err) + } else { + events = append(events, event) + } + } + + return events +} + +func parseTime(sYear, sMonth, sDay string) (int, int, int) { + var ( + year int = 0 + month int = 0 + day int = 0 + err error + reset = false + + now = time.Now() + ) + + if sYear != "" { + year, err = strconv.Atoi(sYear) + if err != nil { + year = 0 + } + } + if sYear == "" || year == 0 { + reset = true + } + + if sMonth != "" { + month, err = strconv.Atoi(sMonth) + if err != nil { + month = 0 + } + } + if sMonth == "" || month == 0 { + reset = true + } + + if sDay != "" { + day, err = strconv.Atoi(sDay) + if err != nil { + day = 0 + } + } + if sDay == "" || day == 0 { + reset = true + } + + if reset { + year, month, day = now.Year(), int(now.Month()), now.Day() + } + + return year, month, day +} diff --git a/static/index.html b/static/index.html index 94966fc..184b135 100755 --- a/static/index.html +++ b/static/index.html @@ -7,19 +7,47 @@ 歷史上的今天 -

歷史上的今天

-
+
+ + +
+ + +

正在載入歷史事件...

-
*以上標註時間均為臺灣標準時間(UTC+8)
+
*以上標註時間均為臺灣標準時間(UTC+8)
+
少了什麼嗎?幫助我們充實資料庫!
+ + +
+

日期

+ +
+ + +
+
+
\ No newline at end of file diff --git a/static/poster.js b/static/poster.js deleted file mode 100644 index 32d94ac..0000000 --- a/static/poster.js +++ /dev/null @@ -1,21 +0,0 @@ -const submit_btn = document.getElementById('submit'); -const title_obj = document.getElementById('title'); -const desc_obj = document.getElementById('desc'); -const date_obj = document.getElementById('date'); -const messenger = document.getElementById('messenger'); - -document.addEventListener('DOMContentLoaded') - -submit_btn.addEventListener(onclick, () => { - var title = title_obj.value; - var desc = desc_obj.value; - var date_str = date_obj.value; - - if (title == null || desc == null || date == null) { - messenger.innerHTML = '請填寫所有欄位'; - messenger.classList.remove("hide"); - return; - } - - const date = new Date(date_str); -}); \ No newline at end of file diff --git a/static/script.js b/static/script.js index 845bb6e..988dd73 100755 --- a/static/script.js +++ b/static/script.js @@ -2,48 +2,157 @@ document.addEventListener('DOMContentLoaded', () => { const dateElement = document.getElementById('current-date'); const eventsList = document.getElementById('events-list'); const timeCommentElement = document.getElementById('time-comment'); - const today = new Date(); + const prevBtn = document.getElementById('prev-btn'); + const nextBtn = document.getElementById('next-btn'); - // Date display - const options = { year: 'numeric', month: 'long', day: 'numeric' }; - dateElement.textContent = today.toLocaleDateString('zh-TW', options); - // Query to API - fetch('/api/query') - .then(response => response.json()) - .then(events => { - eventsList.innerHTML = ''; + const dialog = document.getElementById('date-dialog'); + const datePickerInput = document.getElementById('date-picker-input'); + const cancelDateBtn = document.getElementById('cancel-date-btn'); + const confirmDateBtn = document.getElementById('confirm-date-btn'); - if (events == null || events.length == 0 || events.length == undefined) { - eventsList.innerHTML = '

今天沒有已記載的重大事件。

'; - timeCommentElement.classList.add('hide'); - return; - } - events.forEach(event => { - const card = document.createElement('div'); - card.className = 'event-card'; + let currentDate; + const urlParams = new URLSearchParams(window.location.search); + const yearParam = urlParams.get("year"); + const monthParam = urlParams.get("month"); + const dayParam = urlParams.get("day"); - const title = document.createElement('div'); - title.className = 'event-title'; - title.textContent = event.title; - const year = document.createElement('span'); - year.className = 'event-year'; - year.textContent = `(${event.year} 年)`; + if (yearParam && monthParam && dayParam) { + currentDate = new Date(parseInt(yearParam), parseInt(monthParam) - 1, parseInt(dayParam)); + } else { + currentDate = new Date(); + } - const description = document.createElement('p'); - description.textContent = event.description; - title.appendChild(year); - card.appendChild(title); - card.appendChild(description); - eventsList.appendChild(card); - timeCommentElement.classList.remove('hide'); + fetchAndRender(currentDate); + + function fetchAndRender(date) { + + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + + const newUrl = `?year=${year}&month=${month}&day=${day}`; + window.history.pushState({ path: newUrl }, '', newUrl); + + + const options = { year: 'numeric', month: 'long', day: 'numeric' }; + dateElement.textContent = date.toLocaleDateString('zh-TW', options); + + + eventsList.innerHTML = '

正在載入歷史事件...

'; + timeCommentElement.classList.add('hide'); + + + const requestURL = `/api/query?year=${year}&month=${month}&day=${day}`; + + fetch(requestURL) + .then(response => { + if (!response.ok) throw new Error("Network response was not ok"); + return response.json(); + }) + .then(data => { + renderEvents(data.events); + }) + .catch(error => { + console.error('Fetch error:', error); + eventsList.innerHTML = '

載入歷史事件失敗。請檢查 API 連線。

'; }); - }) - .catch(error => { - console.error('Fetch error:', error); - eventsList.innerHTML = '

載入歷史事件失敗。請檢查 API。

'; + } + + function renderEvents(events) { + eventsList.innerHTML = ''; + + if (!events || events.length === 0) { + eventsList.innerHTML = '

這一天沒有已記載的重大事件。

'; + timeCommentElement.classList.add('hide'); + return; + } + + events.forEach(event => { + const card = document.createElement('div'); + card.className = 'event-card'; + + const title = document.createElement('div'); + title.className = 'event-title'; + title.textContent = event.title; + + const yearSpan = document.createElement('span'); + yearSpan.className = 'event-year'; + yearSpan.textContent = `(${event.year} 年)`; + + const description = document.createElement('p'); + description.textContent = event.description; + + title.appendChild(yearSpan); + card.appendChild(title); + card.appendChild(description); + eventsList.appendChild(card); }); + + timeCommentElement.classList.remove('hide'); + } + + dateElement.addEventListener("wheel", (event) => { + if (event.deltaY > 0) { + nextBtn.click(); + } else if (event.deltaY < 0) { + prevBtn.click(); + } + }) + + prevBtn.addEventListener('click', () => { + currentDate.setDate(currentDate.getDate() - 1); + fetchAndRender(currentDate); + }); + + nextBtn.addEventListener('click', () => { + currentDate.setDate(currentDate.getDate() + 1); + fetchAndRender(currentDate); + }); + + + dateElement.addEventListener('click', () => { + + const yyyy = currentDate.getFullYear(); + const mm = String(currentDate.getMonth() + 1).padStart(2, '0'); + const dd = String(currentDate.getDate()).padStart(2, '0'); + + datePickerInput.value = `${yyyy}-${mm}-${dd}`; + dialog.showModal(); + }); + + + cancelDateBtn.addEventListener('click', () => { + dialog.close(); + }); + + confirmDateBtn.addEventListener('click', () => { + const selectedValue = datePickerInput.value; + if (selectedValue) { + const [y, m, d] = selectedValue.split('-').map(Number); + currentDate = new Date(y, m - 1, d); + fetchAndRender(currentDate); + } + dialog.close(); + }); + + + dialog.addEventListener('click', (event) => { + const rect = dialog.getBoundingClientRect(); + const isInDialog = (rect.top <= event.clientY && event.clientY <= rect.top + rect.height && + rect.left <= event.clientX && event.clientX <= rect.left + rect.width); + if (!isInDialog) { + dialog.close(); + } + }); + + dialog.addEventListener("keypress", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + confirmDateBtn.click(); + } + }) }); \ No newline at end of file diff --git a/static/styles.css b/static/styles.css index dd9f3ca..d06001b 100755 --- a/static/styles.css +++ b/static/styles.css @@ -99,9 +99,179 @@ input[type='date'] { display: none; } -#time-comment { +.comment { font-size: 11px; color: #777; margin-top: 25px; margin-bottom: 0; +} + +.comment>a { + color: inherit; + text-decoration: inherit; +} + +.comment>a::after { + position: absolute; + width: 100%; + transform: scaleX(0); + height: 2px; + bottom: 0; + left: 0; + color: inherit; + transform-origin: bottom right; + transition: transform 0.25s ease-in-out; +} + +.comment>a:hover { + text-decoration: underline; +} + +.comment>a:hover::after { + transform: scaleX(1); + transform-origin: bottom left; +} + +dialog { + border: none; + border-radius: 12px; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2); + padding: 0; + width: 60%; + max-width: 400px; + animation: fadeIn 0.3s ease-out; +} + +dialog::backdrop { + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(2px); +} + +.dialog-content { + padding: 20px; + display: flex; + flex-direction: column; + gap: 15px; + width: 80%; + justify-self: center; + justify-content: center; + margin-left: auto; + margin-right: auto; +} + +.dialog-content h3 { + margin: 0; + color: #333; + font-size: 1.2em; +} + +input[type='date'][id='date-picker-input'] { + padding: 10px; + border: 1px solid #ddd; + border-radius: 6px; + font-size: 16px; + outline: none; + font-family: inherit; + + margin-left: auto; + margin-right: auto; + width: 85%; +} + +input[type='date']:focus { + border-color: #ff69b4; +} + +.dialog-actions { + display: flex; + justify-content: flex-end; + gap: 10px; +} + +button.confirm-btn, +button.cancel-btn { + padding: 8px 16px; + border-radius: 6px; + border: none; + cursor: pointer; + font-size: 0.9em; + transition: background 0.2s; +} + +button.confirm-btn { + background-color: #ff69b4; + color: white; +} + +button.confirm-btn:hover { + background-color: #e0559a; +} + +button.cancel-btn { + background-color: #f0f0f0; + color: #555; +} + +button.cancel-btn:hover { + background-color: #e0e0e0; +} + +.nav-btn { + background: none; + border: none; + cursor: pointer; + color: #555; + padding: 10px; + border-radius: 50%; + transition: background-color 0.2s, color 0.2s; + display: flex; + align-items: center; + justify-content: center; +} + +.nav-btn:hover { + background-color: #ffe6f2; + color: #ff69b4; +} + +.date-controls { + display: flex; + justify-content: center; + align-items: center; + gap: 20px; + margin-bottom: 20px; +} + +#current-date { + margin-bottom: 0; + cursor: pointer; + padding: 5px 15px; + border-radius: 20px; + transition: background-color 0.2s; + font-weight: bold; + user-select: none; + font-size: 100%; +} + +#current-date:hover { + background-color: #f0f0f0; + color: #ff69b4; +} + +/* #current-date::after { + content: ' 📅'; + font-size: 0.8em; + opacity: 0.5; +} */ + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-20px); + } + + to { + opacity: 1; + transform: translateY(0); + } } \ No newline at end of file