Updated user pages

This commit is contained in:
Borna Rajković 2023-12-31 13:35:29 +01:00
parent af2d0f03c6
commit 7a64afd767
26 changed files with 456 additions and 134 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
.idea/**
holiday-api
.env
**/.DS_Store

BIN
assets/background.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

BIN
assets/fonts/Quicksand-Bold.ttf Executable file

Binary file not shown.

BIN
assets/fonts/Quicksand-Light.ttf Executable file

Binary file not shown.

BIN
assets/fonts/Quicksand-Medium.ttf Executable file

Binary file not shown.

Binary file not shown.

Binary file not shown.

203
assets/global.css Normal file
View File

@ -0,0 +1,203 @@
body {
border: 0;
margin: 0;
padding: 0;
font-size: 20px;
}
main {
background: #f3ebe6;
width: 640px;
max-width: 100%;
position: relative;
}
.dialog {
box-sizing: border-box;
border: 1px solid #e0e0e0;
background: white;
margin: auto;
padding: 16px 32px;
}
.background-image {
min-width: 100vw;
min-height: 100vh;
max-width: 100vw;
max-height: 100vh;
background: url("/assets/background.jpg");
background-position: bottom;
background-size: cover;
}
@font-face {
font-family: Quicksand;
src: url(fonts/Quicksand-Light.ttf);
font-weight: 100 200;
}
@font-face {
font-family: Quicksand;
src: url(fonts/Quicksand-Medium.ttf);
font-weight: 300 300;
}
@font-face {
font-family: Quicksand;
src: url(fonts/Quicksand-Regular.ttf);
font-weight: 400 500;
}
@font-face {
font-family: Quicksand;
src: url(fonts/Quicksand-Bold.ttf);
font-weight: 800 900;
}
@font-face {
font-family: Quicksand;
src: url(fonts/Quicksand-SemiBold.ttf);
font-weight: 600 700;
}
* {
font-family: Quicksand, Arial, sans-serif;
}
h1 {
font-size: 2rem;
}
h1 a {
all: unset;
}
.form-field {
width: 100%;
margin-bottom: 16px;
}
.form-field label {
display: block;
color: #999;
}
.form-field input {
width: 300px;
border: none;
border-bottom: 1px solid #bbb;
font-size: 20px;
outline: none;
}
.form-field input:focus {
color: cadetblue;
border-bottom: 1px solid cadetblue;
}
.form-field input:not(focus) {
color: #333;
}
.form-field label {
display: block;
color: #999;
}
.form-field select {
all: unset;
width: 100%;
border: none;
border-bottom: 1px solid #bbb;
font-size: 20px;
outline: none;
}
.form-field select:focus {
color: cadetblue;
border-bottom: 1px solid cadetblue;
}
.form-field select:not(focus) {
color: #333;
}
.form-field:focus-within label {
color: cadetblue;
}
.button.secondary, button.primary {
padding: 12px 16px;
background-color: cadetblue;
font-family: Quicksand, Arial, sans-serif;
border: none;
color: white;
font-size: 1.2rem;
margin-top: 16px;
}
.button.secondary, button.primary:hover {
filter: contrast(140%);
}
.button.secondary, button.secondary {
padding: 12px 16px;
background-color: white;
font-family: Quicksand, Arial, sans-serif;
border: 1px solid cadetblue;
color: cadetblue;
font-size: 1.2rem;
margin-top: 16px;
}
.button.secondary:hover, button.secondary:hover {
filter: contrast(140%);
}
/*index.html*/
.search-dialog {
position: absolute;
top: 0;
left: 0;
width: 100%;
padding-bottom: 40px;
}
@media screen and (min-width: 480px) {
.search-dialog {
position: absolute;
top: 50%;
left: 10vw;
transform: translateY(-50%);
width: 380px;
padding-bottom: 40px;
}
}
/*results.html*/
.results-dialog {
position: relative;
width: 100%;
max-width: 100%;
padding-bottom: 40px;
}
.results-dialog table {
width: 100%;
}
.results-dialog table th, .results-dialog table td {
text-align: left;
}
@media screen and (min-width: 480px) {
.results-dialog {
position: relative;
margin: auto;
width: 600px;
padding-bottom: 40px;
}
}
.results td {
padding: 0.4em 0;
}

View File

@ -1,6 +1,19 @@
const dialogContainerId = "#dialog-container"
window.addEventListener('load', () => {
document.querySelectorAll(".dropdown").forEach(el => {
const action = el.querySelector(".dropdown-action");
const content = el.querySelector(".dropdown-content");
console.log(action, content, el.offsetWidth, content.offsetWidth);
action.addEventListener('click', () => {
content.classList.toggle('selected');
console.log(content.offsetWidth - action.offsetWidth);
content.style.marginLeft = `-${(content.offsetWidth - action.offsetWidth)}px`;
})
})
// configure radio button groups
document.querySelectorAll("section.radio-group").forEach(el => {
const submittedInput = el.querySelector("input[type=hidden]");
@ -23,7 +36,13 @@ window.addEventListener('load', () => {
let response = await fetch(btn.dataset.url);
if(response.ok) {
response = await response.text();
const container = document.querySelector(dialogContainerId);
let container = document.querySelector(dialogContainerId);
if(container == null) {
const node = document.createElement('div');
node.id = "dialog-container";
document.querySelector("body").appendChild(node);
container = document.querySelector(dialogContainerId);
}
container.innerHTML = response;
const dialogReference = document.querySelector(selector);
dialogReference?.showModal();

View File

Before

Width:  |  Height:  |  Size: 795 B

After

Width:  |  Height:  |  Size: 791 B

View File

Before

Width:  |  Height:  |  Size: 807 B

After

Width:  |  Height:  |  Size: 804 B

View File

Before

Width:  |  Height:  |  Size: 602 B

After

Width:  |  Height:  |  Size: 599 B

View File

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

Before

Width:  |  Height:  |  Size: 696 B

After

Width:  |  Height:  |  Size: 693 B

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -1,5 +1,11 @@
/* cleanup */
:root {
--primary: #9222dd;
--primary-darker: #6400a2;
--primary-contrast: #eee;
}
* {
box-sizing: border-box;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
@ -10,22 +16,26 @@
header {
padding: 1rem 2rem;
width: 100%;
background: #666;
background: var(--primary);
color: var(--primary-contrast);
}
header h1 {
font-size: 1.25em;
}
header a {
color: #fff;
color: var(--primary-contrast);
text-decoration: none;
}
button {
all: unset;
padding: 0.25em 0.5em;
background: #dddddd;
border: solid 1px #cccccc;
color: var(--primary-contrast);
background: var(--primary);
border: solid 1px var(--primary-darker);
border-radius: 0.25em;
font-size: 0.875em;
--text: var(--primary-contrast);
}
select {
all: unset;
@ -63,6 +73,10 @@ nav a.selected, nav button.selected {
color: white;
}
table {
border-radius: 0.5em;
}
table:not(.clean) {
width: 100%;
}
@ -71,7 +85,8 @@ table th, table td {
text-align: left;
}
table {
border-collapse: collapse;
border-spacing: 0;
/*border-collapse: collapse;*/
}
table thead * {
background: #666;
@ -184,9 +199,9 @@ section.radio-group div input:checked+label {
color: white;
}
img.icon {
height: 1.5em;
width: 1.5em;
img.icon, button.icon svg {
height: 1.2em;
width: 1.2em;
}
button.icon, a.icon {
display: flex;
@ -194,6 +209,11 @@ button.icon, a.icon {
gap: 0.3em;
}
button.icon svg, button.icon svg * {
fill: var(--text, var(--primary-contrast));
color: var(--text, var(--primary-contrast));
stroke: var(--text, var(--primary-contrast));
}
main {
display: flex;

View File

@ -1,12 +1,15 @@
package main
import (
"bytes"
"encoding/csv"
"encoding/xml"
"fmt"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"holiday-api/holiday"
"net/http"
"strconv"
"time"
)
@ -26,9 +29,9 @@ func getHolidays(service holiday.HolidayService) gin.HandlerFunc {
holidays, err := service.Find(search, paging)
if err != nil {
render(c, http.StatusNotFound, ErrorResponse{Created: time.Now(), Message: "failed fetching holidays"})
render(c, http.StatusNotFound, ErrorResponse{Created: time.Now(), Message: "failed fetching holidays"}, nil)
} else {
render(c, http.StatusOK, mapHolidays(holidays))
render(c, http.StatusOK, mapHolidays(holidays), search.Type)
}
}
}
@ -54,6 +57,18 @@ type HolidayResponse struct {
Holidays []HolidayItemResponse `json:"holidays"`
}
func (h HolidayResponse) CSV() []byte {
buffer := bytes.Buffer{}
csvWriter := csv.NewWriter(&buffer)
csvWriter.Write([]string{"id", "date", "name", "description", "isStateHoliday", "isReligiousHoliday"})
for _, item := range h.Holidays {
csvWriter.Write([]string{item.Id.String(), item.Date.String(), item.Name, item.Description, strconv.FormatBool(item.IsStateHoliday), strconv.FormatBool(item.IsReligiousHoliday)})
}
csvWriter.Flush()
return buffer.Bytes()
}
type HolidayItemResponse struct {
XMLName xml.Name `json:"-" xml:"Holiday"`
Id uuid.UUID `json:"id" xml:"id,attr"`

View File

@ -34,6 +34,7 @@ type Search struct {
RangeEnd *time.Time `form:"range_end" time_format:"2006-01-02"`
StateHoliday string `form:"state_holiday,omitempty" binding:"omitempty"`
ReligiousHoliday string `form:"religious_holiday,omitempty" binding:"omitempty"`
Type *string `form:"type,omitempty" binding:"omitempty"`
}
func (s Search) IsStateHoliday() *bool {

24
main.go
View File

@ -62,16 +62,20 @@ func main() {
setupAdminDashboard(g.Group("/admin"), holidayService, countryService, yearService)
g.GET("/", func(c *gin.Context) {
year := time.Now().Year()
search := holiday.Search{Country: "HR", Year: &year}
search := holiday.Search{Country: "HR", Year: nil}
if err := c.ShouldBindQuery(&search); err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
holidays, _ := holidayService.Find(search, holiday.Paging{PageSize: 100})
countries, _ := countryService.Find()
years, _ := yearService.Find()
c.HTML(http.StatusOK, "index.gohtml", gin.H{"Years": years, "Countries": countries, "Search": search, "Holidays": mapHolidays(holidays).Holidays})
if search.Year == nil {
c.HTML(http.StatusOK, "index.gohtml", gin.H{"Years": years, "Countries": countries, "Search": search})
return
}
holidays, _ := holidayService.Find(search, holiday.Paging{PageSize: 100})
c.HTML(http.StatusOK, "results.gohtml", gin.H{"Years": years, "Countries": countries, "Search": search, "Holidays": mapHolidays(holidays).Holidays})
})
g.GET("/documentation", func(c *gin.Context) {
countries, _ := countryService.Find()
@ -113,9 +117,11 @@ func loadTemplates(g *gin.Engine) {
}
return false
},
"importSvg": IncludeHTML,
})
g.LoadHTMLFiles(
"templates/index.gohtml",
"templates/results.gohtml",
"templates/search.gohtml",
"templates/documentation.gohtml",
@ -132,6 +138,16 @@ func loadTemplates(g *gin.Engine) {
)
}
func IncludeHTML(path string) template.HTML {
b, err := os.ReadFile(path)
if err != nil {
log.Println("includeHTML - error reading file: %v", err)
return ""
}
return template.HTML(string(b))
}
func setupAdminDashboard(adminDashboard *gin.RouterGroup, service holiday.HolidayService, countryService holiday.CountryService, yearService holiday.YearService) {
adminDashboard.Use(gin.BasicAuth(loadAuth()))

View File

@ -4,12 +4,34 @@ import (
"github.com/gin-gonic/gin"
)
func render[T any](c *gin.Context, status int, response T) {
switch c.GetHeader("accept") {
type CSV interface {
CSV() []byte
}
func render(c *gin.Context, status int, response any, contentType *string) {
value := c.GetHeader("accept")
if contentType != nil {
switch *contentType {
case "xml":
value = "text/xml"
case "json":
value = "application/json"
case "csv":
value = "text/csv"
}
}
switch value {
case "text/csv":
if csvResponse, ok := response.(CSV); ok {
c.Data(200, value+"; charset=utf-8", csvResponse.CSV())
} else {
c.Header("content-type", "application/json; charset=utf-8")
c.JSON(status, response)
}
case "application/xml":
fallthrough
case "text/xml":
c.Header("content-type", c.GetHeader("accept")+"; charset=utf-8")
c.Header("content-type", value+"; charset=utf-8; header=present;")
c.XML(status, response)
case "application/json":
fallthrough

View File

@ -61,7 +61,7 @@
</section>
<section class="actions">
<button class="icon primary" type="submit">
<img class="icon" src="/assets/images/search.svg">
{{ importSvg "/assets/icons/search.svg"}}
<span>Search</span>
</button>
</section>
@ -85,11 +85,11 @@
<tr>
<td>{{$entry.Name}}</td>
<td>{{$entry.Date.Format "2006-01-02"}}</td>
<td><img class="icon" src="{{if $entry.IsStateHoliday}}/assets/images/done-v.svg{{else}}/assets/images/close-x.svg{{end}}"></td>
<td><img class="icon" src="{{if $entry.IsReligiousHoliday}}/assets/images/done-v.svg{{else}}/assets/images/close-x.svg{{end}}"></td>
<td><img class="icon" src="{{if $entry.IsStateHoliday}}/assets/icons/done-v.svg{{else}}/assets/icons/close-x.svg{{end}}"></td>
<td><img class="icon" src="{{if $entry.IsReligiousHoliday}}/assets/icons/done-v.svg{{else}}/assets/icons/close-x.svg{{end}}"></td>
<td>
<button data-type="dialog" data-trigger="#update-card" data-url="/admin/dialogs/edit-holiday?id={{$entry.Id}}" class="icon"><img class="icon" src="/assets/images/edit.svg"></button>
<button data-type="dialog" data-trigger="#delete-card" data-url="/admin/dialogs/delete-holiday?id={{$entry.Id}}" class="icon"><img class="icon" src="/assets/images/trash-delete.svg"></button>
<button data-type="dialog" data-trigger="#update-card" data-url="/admin/dialogs/edit-holiday?id={{$entry.Id}}" class="icon"><img class="icon" src="/assets/icons/edit.svg"></button>
<button data-type="dialog" data-trigger="#delete-card" data-url="/admin/dialogs/delete-holiday?id={{$entry.Id}}" class="icon"><img class="icon" src="/assets/icons/trash-delete.svg"></button>
</td>
</tr>
{{end}}

View File

@ -46,8 +46,8 @@
<td>{{$entry.IsoName}}</td>
<td>{{$entry.Name}}</td>
<td>
<button type="button" data-type="dialog" data-trigger="#update-card" data-url="/admin/dialogs/edit-country?id={{$entry.Id}}" class="clean icon"><img class="icon" src="/assets/images/edit.svg"></button>
<button type="button" data-type="dialog" data-trigger="#delete-card" data-url="/admin/dialogs/delete-country?id={{$entry.Id}}" class="clean icon"><img class="icon" src="/assets/images/trash-delete.svg"></button>
<button type="button" data-type="dialog" data-trigger="#update-card" data-url="/admin/dialogs/edit-country?id={{$entry.Id}}" class="clean icon"><img class="icon" src="/assets/icons/edit.svg"></button>
<button type="button" data-type="dialog" data-trigger="#delete-card" data-url="/admin/dialogs/delete-country?id={{$entry.Id}}" class="clean icon"><img class="icon" src="/assets/icons/trash-delete.svg"></button>
</td>
</tr>
{{end}}

View File

@ -1,7 +1,7 @@
<dialog class="card" data-closeable id="check-is-a-holiday">
<div style="display: flex;">
<h2 style="margin-right: 1em;">Is it a holiday?</h2>
<button onclick="closeDialog('#check-is-a-holiday')" class="clean icon"><img class="icon" src="/assets/images/close-x.svg"></button>
<button onclick="closeDialog('#check-is-a-holiday')" class="clean icon"><img class="icon" src="/assets/icons/close-x.svg"></button>
</div>
<form method="get" action="/search">
<section>

View File

@ -1,95 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Holiday-api</title>
<link rel="stylesheet" href="/assets/style.css">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="/assets/global.css">
<script src="/assets/global.js"></script>
</head>
<body>
<div id="dialog-container"></div>
<header>
<section class="container">
<div class="background-image">
<article class="dialog search-dialog">
<h1><a href="/">Holiday-api</a></h1>
</section>
</header>
<nav>
<section class="container">
<a class="selected" href="#">Search</a>
<a href="/documentation">Documentation</a>
<button data-type="dialog" data-trigger="#check-is-a-holiday" data-url="/dialogs/check-is-a-holiday">Check is a holiday</button>
</section>
</nav>
<main>
<section id="search">
<article class="card">
<form action="/" method="get">
<section>
<label for="country">Country:</label>
<select id="country" name="country">
{{range $entry := .Countries}}
<option {{if eq $.Search.Country $entry.IsoName}}selected{{end}} value="{{$entry.IsoName}}">{{$entry.Name}}</option>
{{end}}
</select>
</section>
<section>
<label for="year">Year:</label>
<select id="year" name="year">
{{range $entry := .Years}}
<option {{if intpeq $.Search.Year $entry}}selected{{end}} value="{{$entry}}">{{$entry}}</option>
{{end}}
</select>
</section>
<section class="radio-group">
<label>Is state holiday:</label>
<div>
<input type="radio" value="true" name="sh" id="sh_true"><label for="sh_true">True
</label><input type="radio" value="false" name="sh" id="sh_false"><label for="sh_false">False
</label><input type="radio" value="" name="sh" id="sh_any"><label for="sh_any">All</label>
</div>
<input type="hidden" value="{{.Search.StateHoliday}}" name="state_holiday">
</section>
<section class="radio-group">
<label>Is religious holiday:</label>
<div>
<input type="radio" value="true" name="rh" id="rh_true"><label for="rh_true">True
</label><input type="radio" value="false" name="rh" id="rh_false"><label for="rh_false">False
</label><input type="radio" value="" name="rh" id="rh_any"><label for="rh_any">All</label>
</div>
<input type="hidden" value="{{.Search.ReligiousHoliday}}" name="religious_holiday">
</section>
<section class="actions">
<button class="icon primary" type="submit">
<img class="icon" src="/assets/images/search.svg">
<span>Search</span>
</button>
</section>
</form>
</article>
</section>
<section id="results">
<table>
<thead>
<tr>
<th>Name</th>
<th>Date</th>
<th>State</th>
<th>Religious</th>
</tr>
</thead>
<tbody>
{{range $entry := .Holidays}}
<tr>
<td>{{$entry.Name}}</td>
<td>{{$entry.Date.Format "2006-01-02"}}</td>
<td><img class="icon" src="{{if $entry.IsStateHoliday}}/assets/images/done-v.svg{{else}}/assets/images/close-x.svg{{end}}"></td>
<td><img class="icon" src="{{if $entry.IsReligiousHoliday}}/assets/images/done-v.svg{{else}}/assets/images/close-x.svg{{end}}"></td>
</tr>
{{end}}
</tbody>
</table>
</section>
</main>
<form action="/">
<div class="form-field">
<label for="year">Za godinu:</label>
<select id="year" name="year">
{{range $entry := .Years}}
<option {{if intpeq $.Search.Year $entry}}selected{{end}} value="{{$entry}}">{{$entry}}</option>
{{end}}
</select>
</div>
<div class="button-actions">
<button class="primary">Pretraži</button>
</div>
</form>
</article>
</div>
</body>
</html>

88
templates/results.gohtml Normal file
View File

@ -0,0 +1,88 @@
<!DOCTYPE html>
<html>
<head>
<title>Holiday-api | {{deferint $.Search.Year}}</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="/assets/global.css">
<script src="/assets/global.js"></script>
<style>
.dropdown {
position: relative;
display: inline-block;
}
.dropdown-content {
display: none;
position: absolute;
background-color: #f9f9f9;
min-width: 160px;
box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);
z-index: 1;
}
.dropdown-content.selected {
display: block;
}
.dropdown-content a {
all: unset;
display: block;
padding: 0.5em 1em;
}
.dropdown-content a:hover {
background: rgba(95, 158, 160, 0.2);
}
</style>
</head>
<body>
<div class="background-image" style="position: fixed;"></div>
<article class="dialog results-dialog">
<section style="margin-bottom: 1em;">
<h1><a href="/">Holiday-api</a> | {{deferint $.Search.Year}}</h1>
<form style="display: flex; flex-direction: row; flex-wrap: wrap; align-items: center; gap: 1em" action="/">
<div style="flex-grow: 1;"></div>
<div class="form-field" style="width: auto;" >
<label for="year">Za godinu:</label>
<select id="year" name="year" style="width: 150px">
{{range $entry := .Years}}
<option {{if intpeq $.Search.Year $entry}}selected{{end}} value="{{$entry}}">{{$entry}}</option>
{{end}}
</select>
</div>
<div class="button-actions">
<button class="primary">Pretraži</button>
</div>
<div class="button-actions" style="margin-left: auto">
<div class="dropdown">
<button type="button" class="secondary dropdown-action">Preuzmi</button>
<div class="dropdown-content">
<a target="_blank" href="/api/v1/holidays?country=HR&type=csv&year={{deferint $.Search.Year}}">Kao csv</a>
<a target="_blank" href="/api/v1/holidays?country=HR&type=json&year={{deferint $.Search.Year}}">Kao json</a>
<a target="_blank" href="/api/v1/holidays?country=HR&type=xml&year={{deferint $.Search.Year}}">Kao xml</a>
</div>
</div>
</div>
</form>
</section>
<table class="results">
<thead>
<tr>
<th>Ime</th>
<th>Datum</th>
</tr>
</thead>
<tbody>
{{range $entry := .Holidays}}
<tr>
<td>{{$entry.Name}}</td>
<td>{{$entry.Date.Format "02.01.2006."}}</td>
</tr>
{{end}}
</tbody>
</table>
</article>
</body>
</html>

View File

@ -60,11 +60,11 @@
</tr>
<tr>
<th>Is state holiday: </th>
<td><img class="icon" src="{{if $entry.IsStateHoliday}}/assets/images/done-v.svg{{else}}/assets/images/close-x.svg{{end}}"></td>
<td><img class="icon" src="{{if $entry.IsStateHoliday}}/assets/icons/done-v.svg{{else}}/assets/images/close-x.svg{{end}}"></td>
</tr>
<tr>
<th>Is religious holiday: </th>
<td><img class="icon" src="{{if $entry.IsReligiousHoliday}}/assets/images/done-v.svg{{else}}/assets/images/close-x.svg{{end}}"></td>
<td><img class="icon" src="{{if $entry.IsReligiousHoliday}}/assets/icons/done-v.svg{{else}}/assets/images/close-x.svg{{end}}"></td>
</tr>
</tbody>
</table>