feat: enhanced API and interface

This commit is contained in:
2026-01-01 19:41:01 +08:00
parent e657fd1be5
commit 70addfb510
6 changed files with 557 additions and 60 deletions

2
.gitignore vendored
View File

@@ -2,3 +2,5 @@
/go.mod /go.mod
/go.sum /go.sum
logs/
TodayOnHistory

209
main.go Normal file
View File

@@ -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
}

View File

@@ -7,19 +7,47 @@
<title>歷史上的今天</title> <title>歷史上的今天</title>
<link rel="stylesheet" href="static/styles.css"> <link rel="stylesheet" href="static/styles.css">
<script src="static/script.js"></script> <script src="static/script.js"></script>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<h1 id="title">歷史上的今天</h1> <h1 id="title">歷史上的今天</h1>
<div id="current-date"></div> <div class="date-controls">
<button id="prev-btn" class="nav-btn" aria-label="往前一天">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<div id="current-date" title="更改日期"></div>
<button id="next-btn" class="nav-btn" aria-label="往後一天">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
</div>
<div id="events-list"> <div id="events-list">
<p>正在載入歷史事件...</p> <p>正在載入歷史事件...</p>
</div> </div>
<div id="time-comment" class="hide">*以上標註時間均為臺灣標準時間(UTC+8)</div> <div id="time-comment" class="hide comment">*以上標註時間均為臺灣標準時間(UTC+8)</div>
<div id="help-us" class="comment"><a href="https://forms.gle/jaCTUmfp14nsga1U8"
target="_blank">少了什麼嗎?幫助我們充實資料庫!</a></div>
</div> </div>
<dialog id="date-dialog">
<div class="dialog-content">
<h3>日期</h3>
<input type="date" id="date-picker-input">
<div class="dialog-actions">
<button id="cancel-date-btn" class="cancel-btn">取消</button>
<button id="confirm-date-btn" class="confirm-btn">確認</button>
</div>
</div>
</dialog>
</body> </body>
</html> </html>

View File

@@ -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);
});

View File

@@ -2,48 +2,157 @@ document.addEventListener('DOMContentLoaded', () => {
const dateElement = document.getElementById('current-date'); const dateElement = document.getElementById('current-date');
const eventsList = document.getElementById('events-list'); const eventsList = document.getElementById('events-list');
const timeCommentElement = document.getElementById('time-comment'); 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 const dialog = document.getElementById('date-dialog');
fetch('/api/query') const datePickerInput = document.getElementById('date-picker-input');
.then(response => response.json()) const cancelDateBtn = document.getElementById('cancel-date-btn');
.then(events => { const confirmDateBtn = document.getElementById('confirm-date-btn');
eventsList.innerHTML = '';
if (events == null || events.length == 0 || events.length == undefined) {
eventsList.innerHTML = '<p>今天沒有已記載的重大事件。</p>';
timeCommentElement.classList.add('hide');
return;
}
events.forEach(event => { let currentDate;
const card = document.createElement('div'); const urlParams = new URLSearchParams(window.location.search);
card.className = 'event-card'; 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'); if (yearParam && monthParam && dayParam) {
year.className = 'event-year'; currentDate = new Date(parseInt(yearParam), parseInt(monthParam) - 1, parseInt(dayParam));
year.textContent = `(${event.year} 年)`; } else {
currentDate = new Date();
}
const description = document.createElement('p');
description.textContent = event.description;
title.appendChild(year); fetchAndRender(currentDate);
card.appendChild(title);
card.appendChild(description); function fetchAndRender(date) {
eventsList.appendChild(card);
timeCommentElement.classList.remove('hide'); 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 = '<p>正在載入歷史事件...</p>';
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 = '<p style="color: red;">載入歷史事件失敗。請檢查 API 連線。</p>';
}); });
}) }
.catch(error => {
console.error('Fetch error:', error); function renderEvents(events) {
eventsList.innerHTML = '<p style="color: red;">載入歷史事件失敗。請檢查 API。</p>'; eventsList.innerHTML = '';
if (!events || events.length === 0) {
eventsList.innerHTML = '<p>這一天沒有已記載的重大事件。</p>';
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();
}
})
}); });

View File

@@ -99,9 +99,179 @@ input[type='date'] {
display: none; display: none;
} }
#time-comment { .comment {
font-size: 11px; font-size: 11px;
color: #777; color: #777;
margin-top: 25px; margin-top: 25px;
margin-bottom: 0; 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);
}
}