Updated user pages

This commit is contained in:
Borna Rajković committed 2023-12-31 13:35:29 +01:00
1 parent af2d0f03c6
commit 7a64afd767
26 files changed
+436 -114

No files matched your search

+1
View File
@@ -1,3 +1,4 @@
.idea/**
holiday-api
.env
**/.DS_Store
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
+203
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;
}
+20 -1
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();
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+28 -8
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;
+17 -2
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"`
+1
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 {
+20 -4
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()))
+25 -3
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
+5 -5
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}}
+2 -2
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}}
+1 -1
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>
+23 -86
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
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>
+2 -2
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>