feat: enhanced API and interface
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -2,3 +2,5 @@
|
||||
|
||||
/go.mod
|
||||
/go.sum
|
||||
logs/
|
||||
TodayOnHistory
|
||||
209
main.go
Normal file
209
main.go
Normal 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
|
||||
}
|
||||
@@ -7,19 +7,47 @@
|
||||
<title>歷史上的今天</title>
|
||||
<link rel="stylesheet" href="static/styles.css">
|
||||
<script src="static/script.js"></script>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<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">
|
||||
<p>正在載入歷史事件...</p>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
|
||||
</html>
|
||||
@@ -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);
|
||||
});
|
||||
143
static/script.js
143
static/script.js
@@ -2,20 +2,71 @@ 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');
|
||||
|
||||
|
||||
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');
|
||||
|
||||
|
||||
let currentDate;
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const yearParam = urlParams.get("year");
|
||||
const monthParam = urlParams.get("month");
|
||||
const dayParam = urlParams.get("day");
|
||||
|
||||
|
||||
if (yearParam && monthParam && dayParam) {
|
||||
currentDate = new Date(parseInt(yearParam), parseInt(monthParam) - 1, parseInt(dayParam));
|
||||
} else {
|
||||
currentDate = new Date();
|
||||
}
|
||||
|
||||
|
||||
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);
|
||||
|
||||
|
||||
// Date display
|
||||
const options = { year: 'numeric', month: 'long', day: 'numeric' };
|
||||
dateElement.textContent = today.toLocaleDateString('zh-TW', options);
|
||||
dateElement.textContent = date.toLocaleDateString('zh-TW', options);
|
||||
|
||||
// Query to API
|
||||
fetch('/api/query')
|
||||
.then(response => response.json())
|
||||
.then(events => {
|
||||
|
||||
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>';
|
||||
});
|
||||
}
|
||||
|
||||
function renderEvents(events) {
|
||||
eventsList.innerHTML = '';
|
||||
|
||||
if (events == null || events.length == 0 || events.length == undefined) {
|
||||
eventsList.innerHTML = '<p>今天沒有已記載的重大事件。</p>';
|
||||
if (!events || events.length === 0) {
|
||||
eventsList.innerHTML = '<p>這一天沒有已記載的重大事件。</p>';
|
||||
timeCommentElement.classList.add('hide');
|
||||
return;
|
||||
}
|
||||
@@ -28,22 +79,80 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
title.className = 'event-title';
|
||||
title.textContent = event.title;
|
||||
|
||||
const year = document.createElement('span');
|
||||
year.className = 'event-year';
|
||||
year.textContent = `(${event.year} 年)`;
|
||||
const yearSpan = document.createElement('span');
|
||||
yearSpan.className = 'event-year';
|
||||
yearSpan.textContent = `(${event.year} 年)`;
|
||||
|
||||
const description = document.createElement('p');
|
||||
description.textContent = event.description;
|
||||
|
||||
title.appendChild(year);
|
||||
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();
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Fetch error:', error);
|
||||
eventsList.innerHTML = '<p style="color: red;">載入歷史事件失敗。請檢查 API。</p>';
|
||||
|
||||
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();
|
||||
}
|
||||
})
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user