Author SHA1 Message Date
brajkovic 67c72e8b4f Merge pull request 'v1.0.0' (#2) from feature/reorganizing-code into main
Reviewed-on: #2
2024-02-10 17:07:44 +00:00
Borna Rajković c6f365ab28 v1.0.0
Code reorganization + added meta tags and favicon
2024-02-10 17:49:00 +01:00
brajkovic 2b61f9e0b1 Update 'README.md'
Added reference to production deployment
2024-02-09 09:52:35 +00:00
Borna Rajković 45b220c69f v0.5.0
added year duplication
2024-02-08 21:30:20 +01:00
Borna Rajković 8fbdffc965 v0.4.0
fixed sorting and added dark theme
2024-02-08 20:48:23 +01:00
Borna Rajković 76ef62bb96 v0.3.1
updated default year used on frontend
2024-01-07 22:01:58 +01:00
Borna Rajković 6bd0c9e37c v0.3.0 2024-01-01 18:00:14 +01:00
Borna Rajković 0173086939 Removed unused methods 2024-01-01 17:58:47 +01:00
Borna Rajković 9bdb5dd129 Redesigned admin pages 2024-01-01 12:29:59 +01:00
Borna Rajković 6163c0a04f Typo 2023-12-31 13:36:27 +01:00
Borna Rajković 7a64afd767 Updated user pages 2023-12-31 13:35:29 +01:00
Borna Rajković af2d0f03c6 Database fix 2023-11-19 11:52:31 +01:00
Borna Rajković ef526e6ff4 Standardized styles 2023-11-19 11:47:30 +01:00
brajkovic 99c5e5cd13 Update 'README.md' 2023-08-25 06:07:35 +00:00
50 changed files with 1435 additions and 1038 deletions

No files matched your search

+2
View File
@@ -1,3 +1,5 @@
.idea/** .idea/**
holiday-api holiday-api
.env .env
**/.DS_Store
.env*
+10 -4
View File
@@ -2,6 +2,8 @@
Simple api used for tracking holidays Simple api used for tracking holidays
To checkout application open [https://holiday.bbr-dev.info](https://holiday.bbr-dev.info)
## Endpoints ## Endpoints
Fetching is done via `GET /api/v1/holidays` endpoint Fetching is done via `GET /api/v1/holidays` endpoint
@@ -55,25 +57,29 @@ That endpoint accepts a list of required and optional parameters
By default, responses are returned as a json array By default, responses are returned as a json array
``` ```
[{ {
holidays: [{
id: string; id: string;
date: string(ISO 8601); date: string(ISO 8601);
name: string; name: string;
description: string; description: string;
isStateHoliday: boolean; isStateHoliday: boolean;
isReligiousHoliday: boolean; isReligiousHoliday: boolean;
},...] },...]
}
``` ```
eg. eg.
``` ```
[{ {
"holidays": [{
"id": "74a2a769-abf2-45d4-bdc4-442bbcc89138", "id": "74a2a769-abf2-45d4-bdc4-442bbcc89138",
"date": "2023-12-25", "date": "2023-12-25",
"name": "Christmas", "name": "Christmas",
"description": "TBD", "description": "TBD",
"isStateHoliday": true, "isStateHoliday": true,
"isReligiousHoliday": true "isReligiousHoliday": true
}] }]
}
``` ```
But can be returned as XML or CSV by setting appropriate `Accept` header (application/xml, text/xml or text/csv) But can be returned as XML or CSV by setting appropriate `Accept` header (application/xml, text/xml or text/csv)
+351
View File
@@ -0,0 +1,351 @@
package api
import (
"fmt"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"holiday-api/domain/holiday"
"log"
"net/http"
"strconv"
"time"
)
func NoMethod() gin.HandlerFunc {
return func(c *gin.Context) {
c.AbortWithError(http.StatusNotFound, nil)
}
}
func NoRoute() gin.HandlerFunc {
return func(c *gin.Context) {
c.AbortWithError(http.StatusNotFound, nil)
}
}
func GetIndex(holidayService holiday.HolidayService, countryService holiday.CountryService, yearService holiday.YearService) gin.HandlerFunc {
return func(c *gin.Context) {
search := holiday.Search{Country: "HR", Year: nil}
if err := c.ShouldBindQuery(&search); err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
years, _ := yearService.Find()
if search.Year == nil {
c.HTML(http.StatusOK, "index.gohtml", gin.H{"Year": time.Now().Year(), "Years": years})
return
}
countries, _ := countryService.Find()
holidays, _ := holidayService.Find(search, holiday.Paging{PageSize: 100})
c.HTML(http.StatusOK, "results.gohtml", gin.H{"Years": years, "Countries": countries, "Search": search, "Holidays": holiday.ToResponse(holidays).Holidays})
}
}
func GetDocumentation(countryService holiday.CountryService, yearService holiday.YearService) gin.HandlerFunc {
return func(c *gin.Context) {
countries, _ := countryService.Find()
years, _ := yearService.Find()
c.HTML(http.StatusOK, "documentation.gohtml", gin.H{"Year": time.Now().Year(), "Years": years, "Countries": countries})
}
}
func GetHolidays(service holiday.HolidayService) gin.HandlerFunc {
return func(c *gin.Context) {
paging := holiday.Paging{PageSize: 50}
if err := c.ShouldBindQuery(&paging); err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
search := holiday.Search{}
if err := c.ShouldBindQuery(&search); err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
holidays, err := service.Find(search, paging)
if err != nil {
render(c, http.StatusNotFound, holiday.ErrorResponse{Created: time.Now(), Message: "failed fetching holidays"}, nil)
} else {
render(c, http.StatusOK, holiday.ToResponse(holidays), search.Type)
}
}
}
func DeleteCountry(countryService holiday.CountryService) gin.HandlerFunc {
return func(c *gin.Context) {
id := uuid.MustParse(c.Param("id"))
_, err := countryService.FindById(id)
if err != nil {
Abort(c, err, http.StatusNotFound, "couldn't find country")
return
}
if err := countryService.Delete(id); err != nil {
Abort(c, err, http.StatusInternalServerError, "couldn't delete country")
return
}
c.Redirect(http.StatusSeeOther, "/admin/countries")
}
}
func CreateOrUpdateCountry(countryService holiday.CountryService) gin.HandlerFunc {
return func(c *gin.Context) {
request := struct {
Id *string `form:"id"`
IsoName string `form:"iso_name" binding:"required,min=2,max=2"`
Name string `form:"name" binding:"required,min=1,max=45"`
}{}
if err := c.ShouldBind(&request); err != nil {
Abort(c, err, http.StatusInternalServerError, "invalid request when creating or updating country")
return
}
country := holiday.Country{
IsoName: request.IsoName,
Name: request.Name,
}
var err error
if request.Id != nil {
country.Id = uuid.MustParse(*request.Id)
country, err = countryService.Update(country)
} else {
country, err = countryService.Create(country)
}
if err != nil {
Abort(c, err, http.StatusInternalServerError, "couldn't create or update country")
} else {
c.Redirect(http.StatusSeeOther, "/admin/countries")
}
}
}
func GetCountries(countryService holiday.CountryService) gin.HandlerFunc {
return func(c *gin.Context) {
countries, _ := countryService.Find()
c.HTML(http.StatusOK, "countries.gohtml", gin.H{"Countries": countries})
}
}
func DeleteHoliday(holidayService holiday.HolidayService) gin.HandlerFunc {
return func(c *gin.Context) {
id := uuid.MustParse(c.Param("id"))
hol, err := holidayService.FindById(id)
if err != nil {
Abort(c, err, http.StatusNotFound, "couldn't find holiday to delete")
return
}
if err := holidayService.Delete(id); err != nil {
Abort(c, err, http.StatusInternalServerError, "couldn't delete holiday")
return
}
c.Redirect(http.StatusSeeOther, "/admin?country="+hol.Country+"&year="+strconv.FormatInt(int64(hol.Date.Year()), 10))
}
}
func CopyYear(holidayService holiday.HolidayService) gin.HandlerFunc {
return func(c *gin.Context) {
request := struct {
From int `form:"from"`
To int `form:"to"`
Country string `form:"country" binding:"len=2"`
}{}
if err := c.ShouldBind(&request); err != nil {
Abort(c, err, http.StatusBadRequest, "invalid request when copying holiday")
return
}
err := holidayService.Copy(request.Country, request.From, request.To)
if err != nil {
Abort(c, err, http.StatusInternalServerError, "couldn't copy holidays")
} else {
c.Redirect(http.StatusSeeOther, "/admin?country="+request.Country+"&year="+strconv.FormatInt(int64(request.To), 10))
}
}
}
func CreateOrUpdateHoliday(holidayService holiday.HolidayService) gin.HandlerFunc {
return func(c *gin.Context) {
request := struct {
Id *string `form:"id"`
Name string `form:"name" binding:"required,min=1"`
Description string `form:"description"`
IsStateHoliday bool `form:"state_holiday"`
IsReligiousHoliday bool `form:"religious_holiday"`
Country string `form:"country" binding:"len=2"`
Date time.Time `form:"date" time_format:"2006-01-02"`
}{}
if err := c.ShouldBind(&request); err != nil {
Abort(c, err, http.StatusBadRequest, "invalid request when creating holiday")
return
}
hol := holiday.Holiday{
Country: request.Country,
Date: request.Date,
Name: request.Name,
Description: request.Description,
IsStateHoliday: request.IsStateHoliday,
IsReligiousHoliday: request.IsReligiousHoliday,
}
var err error
if request.Id != nil {
hol.Id = uuid.MustParse(*request.Id)
hol, err = holidayService.Update(hol)
} else {
hol, err = holidayService.Create(hol)
}
if err != nil {
Abort(c, err, 500, "couldn't create or update holiday")
} else {
c.Redirect(http.StatusSeeOther, "/admin?country="+request.Country+"&year="+strconv.FormatInt(int64(request.Date.Year()), 10))
}
}
}
func GetAdminHome(holidayService holiday.HolidayService, countryService holiday.CountryService, yearService holiday.YearService) gin.HandlerFunc {
return func(c *gin.Context) {
search := holiday.Search{Country: "HR", Year: new(int)}
*search.Year = time.Now().Year()
if err := c.ShouldBindQuery(&search); err != nil {
Abort(c, err, http.StatusBadRequest, "invalid search parameters")
return
}
holidays, _ := holidayService.Find(search, holiday.Paging{PageSize: 100})
holidayResponse := holiday.ToResponse(holidays)
countries, _ := countryService.Find()
years, _ := yearService.Find()
response := map[string]any{}
response["Holidays"] = holidayResponse
response["Search"] = search
response["Countries"] = countries
response["Years"] = years
c.HTML(http.StatusOK, "admin_dashboard.gohtml", response)
}
}
func AddHolidayDialog(countryService holiday.CountryService) gin.HandlerFunc {
return func(c *gin.Context) {
countries, _ := countryService.Find()
c.HTML(http.StatusOK, "add-holiday.gohtml", gin.H{"Countries": countries})
}
}
func EditHolidayDialog(holidayService holiday.HolidayService, countryService holiday.CountryService) gin.HandlerFunc {
return func(c *gin.Context) {
id := uuid.MustParse(c.Query("id"))
hol, err := holidayService.FindById(id)
if err != nil {
Abort(c, err, http.StatusNotFound, "couldn't find holiday to edit")
return
}
countries, _ := countryService.Find()
c.HTML(http.StatusOK, "edit-holiday.gohtml", gin.H{"Countries": countries, "Holiday": hol})
}
}
func DeleteHolidayDialog(holidayService holiday.HolidayService) gin.HandlerFunc {
return func(c *gin.Context) {
id := uuid.MustParse(c.Query("id"))
hol, err := holidayService.FindById(id)
if err != nil {
Abort(c, err, http.StatusNotFound, "couldn't find dialog")
return
}
c.HTML(http.StatusOK, "delete-holiday.gohtml", gin.H{"Holiday": hol})
}
}
func CopyYearDialog() gin.HandlerFunc {
return func(c *gin.Context) {
country := c.Query("country")
year, err := strconv.ParseInt(c.Query("year"), 10, 32)
if err != nil {
Abort(c, err, http.StatusNotFound, "couldn't parse year")
return
}
c.HTML(http.StatusOK, "copy-year.gohtml", gin.H{"Country": country, "Year": year})
}
}
func AddCountryDialog() gin.HandlerFunc {
return func(c *gin.Context) {
c.HTML(http.StatusOK, "add-country.gohtml", gin.H{})
}
}
func EditCountryDialog(countryService holiday.CountryService) gin.HandlerFunc {
return func(c *gin.Context) {
id := uuid.MustParse(c.Query("id"))
country, err := countryService.FindById(id)
if err != nil {
Abort(c, err, http.StatusNotFound, "couldn't find country")
return
}
c.HTML(http.StatusOK, "edit-country.gohtml", gin.H{"Country": country})
}
}
func DeleteCountryDialog(countryService holiday.CountryService) gin.HandlerFunc {
return func(c *gin.Context) {
id := uuid.MustParse(c.Query("id"))
country, err := countryService.FindById(id)
if err != nil {
Abort(c, err, http.StatusNotFound, "couldn't find dialog")
return
}
c.HTML(http.StatusOK, "delete-country.gohtml", gin.H{"Country": country})
}
}
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", value+"; charset=utf-8; header=present;")
c.XML(status, response)
case "application/json":
fallthrough
default:
c.Header("content-type", "application/json; charset=utf-8")
c.JSON(status, response)
}
}
func Abort(c *gin.Context, err error, statusCode int, message string) {
log.Output(1, fmt.Sprintf("error | %s | err: %v", message, errorMessage(err)))
c.AbortWithError(statusCode, err)
}
func errorMessage(err error) string {
if err != nil {
return err.Error()
}
return "-"
}
+39
View File
@@ -0,0 +1,39 @@
package api
import (
"fmt"
"github.com/gin-gonic/gin"
"os"
"strings"
)
func Auth() gin.HandlerFunc {
if hasProfile("basic-auth") {
return gin.BasicAuth(loadAuth())
}
panic("keycloak support not implemented")
}
func hasProfile(value string) bool {
profileOptions := strings.Split(os.Getenv("PROFILE"), ",")
for _, option := range profileOptions {
if option == value {
return true
}
}
return false
}
func loadAuth() map[string]string {
credentials := envMustExist("AUTH_KEY")
values := strings.Split(credentials, ":")
return map[string]string{values[0]: values[1]}
}
func envMustExist(env string) string {
if value, exists := os.LookupEnv(env); !exists {
panic(fmt.Sprintf("env variable '%s' not defined", env))
} else {
return value
}
}
+39
View File
@@ -0,0 +1,39 @@
package api
import (
"github.com/gin-gonic/gin"
"github.com/jmoiron/sqlx"
"holiday-api/domain/holiday"
)
func RegisterRoutes(server *gin.Engine, db *sqlx.DB) {
holidayService := holiday.HolidayService{DB: db}
countryService := holiday.CountryService{DB: db}
yearService := holiday.YearService{DB: db}
server.GET("/", GetIndex(holidayService, countryService, yearService))
server.GET("/documentation", GetDocumentation(countryService, yearService))
server.GET("/api/v1/holidays", GetHolidays(holidayService))
auth := Auth()
adminGroup := server.Group("/admin", auth)
adminGroup.GET("/", GetAdminHome(holidayService, countryService, yearService))
adminGroup.POST("/holidays", CreateOrUpdateHoliday(holidayService))
adminGroup.POST("/holidays/copy", CopyYear(holidayService))
adminGroup.POST("/holidays/:id/delete", DeleteHoliday(holidayService))
adminGroup.GET("countries", GetCountries(countryService))
adminGroup.POST("countries", CreateOrUpdateCountry(countryService))
adminGroup.POST("countries/:id/delete", DeleteCountry(countryService))
dialogGroup := server.Group("/admin/dialogs", auth)
dialogGroup.GET("/add-country", AddCountryDialog())
dialogGroup.GET("/edit-country", EditCountryDialog(countryService))
dialogGroup.GET("/delete-country", DeleteCountryDialog(countryService))
dialogGroup.GET("/add-holiday", AddHolidayDialog(countryService))
dialogGroup.GET("/edit-holiday", EditHolidayDialog(holidayService, countryService))
dialogGroup.GET("/delete-holiday", DeleteHolidayDialog(holidayService))
dialogGroup.GET("/copy-year", CopyYearDialog())
}
+22
View File
@@ -0,0 +1,22 @@
package api
import (
"github.com/gin-gonic/gin"
"github.com/jmoiron/sqlx"
)
func SetupServer(db *sqlx.DB) *gin.Engine {
server := createServer()
LoadTemplates(server)
RegisterRoutes(server, db)
return server
}
func createServer() *gin.Engine {
server := gin.New()
server.NoRoute(NoRoute())
server.NoMethod(NoMethod())
server.Use(gin.Recovery())
server.Static("assets", "assets")
return server
}
+43
View File
@@ -0,0 +1,43 @@
package api
import (
"github.com/gin-gonic/gin"
"html/template"
)
func LoadTemplates(server *gin.Engine) {
server.SetFuncMap(template.FuncMap{
"boolcmp": func(value *bool, expected string) bool {
if value == nil {
return expected == "nil"
} else {
return (*value && expected == "true") || (!(*value) && expected == "false")
}
},
"deferint": func(value *int) int { return *value },
"intpeq": func(selected *int, value int) bool {
if selected != nil {
return *selected == value
}
return false
},
})
server.LoadHTMLFiles(
"templates/index.gohtml",
"templates/results.gohtml",
"templates/documentation.gohtml",
"templates/admin_dashboard.gohtml",
"templates/countries.gohtml",
"templates/dialogs/copy-year.gohtml",
"templates/dialogs/add-holiday.gohtml",
"templates/dialogs/edit-holiday.gohtml",
"templates/dialogs/delete-holiday.gohtml",
"templates/dialogs/add-country.gohtml",
"templates/dialogs/edit-country.gohtml",
"templates/dialogs/delete-country.gohtml",
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 633 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

+10 -12
View File
@@ -11,17 +11,15 @@ window.addEventListener('load', () => {
} }
function generateQuery(query) { function generateQuery(query) {
initialQuery = `curl -H "accept: ${query['contentType']}" "https://holiday.bbr-dev.info/api/v1/holidays?country=${query['country']}` initialQuery = `https://holiday.bbr-dev.info/api/v1/holidays?country=${query['country']}`
delete query['contentType'];
delete query['country']; delete query['country'];
for(const key in query) { for(const key in query) {
initialQuery += `&${key}=${query[key]}` initialQuery += `&${key}=${query[key]}`
} }
initialQuery+='"'; document.querySelector('#query-link').href = initialQuery;
document.querySelector('#result-content').innerHTML = "curl \"" + initialQuery + "\"";
document.querySelector('#result-content').innerHTML = initialQuery;
} }
let country = document.querySelector("#country"); let country = document.querySelector("#country");
@@ -36,10 +34,8 @@ window.addEventListener('load', () => {
let dDate = document.querySelector("#dsd-date"); let dDate = document.querySelector("#dsd-date");
let dStartRange = document.querySelector("#dsr-start-range"); let dStartRange = document.querySelector("#dsr-start-range");
let dStartRangeRequired = document.querySelector("#dsr-start-range-required");
let dEndRange = document.querySelector("#dsr-end-range"); let dEndRange = document.querySelector("#dsr-end-range");
let dEndRangeRequired = document.querySelector("#dsr-end-range-required");
let queryGenerator = document.querySelector("#query-generator"); let queryGenerator = document.querySelector("#query-generator");
@@ -53,6 +49,7 @@ window.addEventListener('load', () => {
if(religiousHoliday.value === 'true' || religiousHoliday.value === 'false') { if(religiousHoliday.value === 'true' || religiousHoliday.value === 'false') {
query['religiousHoliday'] = parseBoolean(religiousHoliday.value); query['religiousHoliday'] = parseBoolean(religiousHoliday.value);
} }
console.log(dateSelector.value, dStartRange.value, dEndRange.value);
switch(dateSelector.value) { switch(dateSelector.value) {
case 'year': case 'year':
query['year'] = dYear.value; query['year'] = dYear.value;
@@ -63,10 +60,10 @@ window.addEventListener('load', () => {
} }
break; break;
case 'range': case 'range':
if(dStartRangeRequired.checked && dStartRange.value) { if(dStartRange.value) {
query['startRange'] = dStartRange.value; query['startRange'] = dStartRange.value;
} }
if(dEndRangeRequired.checked && dEndRange.value) { if(dEndRange.value) {
query['endRange'] = dEndRange.value; query['endRange'] = dEndRange.value;
} }
case 'all': case 'all':
@@ -79,13 +76,14 @@ window.addEventListener('load', () => {
queryGenerator.addEventListener('change', event => { queryGenerator.addEventListener('change', event => {
const query = {}; const query = {};
query['country'] = country.value; query['country'] = country.value;
query['contentType'] = contentType.value; query['type'] = contentType.value;
if(stateHoliday.value === 'true' || stateHoliday.value === 'false') { if(stateHoliday.value === 'true' || stateHoliday.value === 'false') {
query['stateHoliday'] = parseBoolean(stateHoliday.value); query['stateHoliday'] = parseBoolean(stateHoliday.value);
} }
if(religiousHoliday.value === 'true' || religiousHoliday.value === 'false') { if(religiousHoliday.value === 'true' || religiousHoliday.value === 'false') {
query['religiousHoliday'] = parseBoolean(religiousHoliday.value); query['religiousHoliday'] = parseBoolean(religiousHoliday.value);
} }
console.log(dateSelector.value, dStartRange.value, dEndRange.value);
switch(dateSelector.value) { switch(dateSelector.value) {
case 'year': case 'year':
query['year'] = dYear.value; query['year'] = dYear.value;
@@ -96,10 +94,10 @@ window.addEventListener('load', () => {
} }
break; break;
case 'range': case 'range':
if(dStartRangeRequired.checked && dStartRange.value) { if(dStartRange.value) {
query['startRange'] = dStartRange.value; query['startRange'] = dStartRange.value;
} }
if(dEndRangeRequired.checked && dEndRange.value) { if(dEndRange.value) {
query['endRange'] = dEndRange.value; query['endRange'] = dEndRange.value;
} }
case 'all': case 'all':
Binary file not shown.

After

Width:  |  Height:  |  Size: 766 B

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.
+482
View File
@@ -0,0 +1,482 @@
body {
border: 0;
margin: 0;
padding: 0;
}
:root {
--background-color: #f3ebe6;
--background: #ffffff;
--color: #000;
--border: #dddddd;
--form-label: #999;
--form-border: #bbb;
--form-focus: #333;
--dialog-border: #5f9ea07f;
--dialog-backdrop: #979f9f63;
--dropdown-background: #f0f0f0;
--primary: #5f9ea0;
--primary-contrast: #ffffff;
--primary-accent: #6a7579;
--code-background: #aaa;
--code-color: #000;
}
@media (prefers-color-scheme: dark) {
:root {
--background-color: #f3ebe6;
--background: #333;
--color: #f0f0f0;
--border: #666;
--form-label: #999;
--form-border: #555;
--form-focus: #ccc;
--dialog-border: rgba(16, 173, 173, 0.5);
--dialog-backdrop: rgba(1, 70, 70, 0.39);
--dropdown-background: #222;
--primary: #0e8d93;
--primary-contrast: #333;
--primary-accent: #08363a;
--code-background: #444;
--code-color: #d0d0d0;
}
}
* {
color: var(--color);
background: var(--background);
}
main {
background: var(--background-color);
width: 640px;
max-width: 100%;
position: relative;
}
header {
width: 100%;
box-sizing: border-box;
padding: 1em;
background: var(--background);
position: sticky;
z-index: 10;
top: 0;
border-bottom: 1px solid var(--border);
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: baseline;
gap: 1em;
}
.dialog {
box-sizing: border-box;
border: 1px solid var(--border);
background: var(--background);
margin: auto;
padding: 1em 0.5em;
}
@media screen and (min-width: 480px) {
.dialog {
padding: 1em 2em;
}
}
.background-image {
min-width: 100vw;
min-height: 100dvh;
max-width: 100vw;
max-height: 100dvh;
background: url("/assets/background.jpg");
background-position: bottom;
background-size: cover;
}
@media (prefers-color-scheme: dark) {
.background-image {
min-width: 100vw;
min-height: 100dvh;
max-width: 100vw;
max-height: 100dvh;
background: url("/assets/background-dark.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: 1.1rem;
margin: 0;
}
h1 a {
all: unset;
}
h3 {
margin: 0.5em 0;
}
@media screen and (min-width: 480px) {
h1 {
font-size: 1.6rem;
}
}
.form-field {
width: 100%;
margin-bottom: 1em;
}
.form-field label {
display: block;
color: var(--form-label);
}
.form-field input, .form-field textarea {
box-sizing: border-box;
width: 100%;
border: none;
border-bottom: 1px solid var(--form-border);
font-size: 1rem;
outline: none;
}
.form-field .checkbox {
display: inline;
}
.form-field input[type=checkbox] {
width: inherit;
margin-left: 1em;
accent-color: var(--primary-accent);
}
.form-field input:focus, .form-field textarea:focus {
color: var(--primary);
border-bottom: 1px solid var(--primary);
}
.form-field input:not(focus), .form-field textarea:not(focus) {
color: var(--form-focus);
}
.form-field select {
all: unset;
width: 100%;
border: none;
border-bottom: 1px solid var(--form-border);
font-size: 1rem;
outline: none;
}
.form-field select:focus {
color: var(--primary);
border-bottom: 1px solid var(--primary);
}
.form-field select:not(focus) {
color: var(--form-focus);
}
.form-field:focus-within label {
color: var(--primary);
}
.button.primary, button.primary {
padding: 0.75em 1em;
background-color: var(--primary);
font-family: Quicksand, Arial, sans-serif;
border: none;
color: var(--primary-contrast);
font-size: 1rem;
margin-top: 1em;
}
.button.secondary, button.secondary {
padding: 0.75em 1em;
background-color: var(--primary-contrast);
font-family: Quicksand, Arial, sans-serif;
border: 1px solid var(--primary);
color: var(--primary);
font-size: 1rem;
margin-top: 1em;
}
.button.primary, button.primary:hover {
filter: contrast(140%);
}
.button.secondary:hover, button.secondary:hover {
filter: contrast(80%);
}
@media (prefers-color-scheme: dark) {
.button.primary, button.primary:hover {
filter: brightness(140%);
}
.button.secondary:hover, button.secondary:hover {
filter: brightness(140%);
}
}
/*index.html*/
.search-dialog {
position: relative;
width: 100%;
padding-top: 40px;
padding-bottom: 40px;
}
@media screen and (min-width: 480px) {
.search-dialog {
position: absolute;
top: 50%;
left: 10vw;
transform: translateY(-50%);
width: 380px;
padding-top: 40px;
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;
margin-top: 1em;
width: 600px;
padding-bottom: 40px;
}
}
.results td {
padding: 0.4em 0;
}
/*documentation.html*/
.documentation-dialog {
position: relative;
width: 100%;
max-width: 100%;
}
@media screen and (min-width: 480px) {
.documentation-dialog {
position: relative;
margin: auto;
margin-top: 1em;
width: 600px;
}
}
.documentation-dialog #result-content {
font-family: 'Courier New', Courier, monospace;
background: var(--code-background);
color: var(--code-color);
padding: 1em;
word-break: break-word;
}
section.radio-group {
margin-bottom: 0.5em;
}
section.radio-group input {
display: none;
}
section.radio-group div label {
display: inline-block;
padding: 0.75em 1em;
border: none;
color: var(--color);
font-size: 1rem;
background: var(--border);
}
section.radio-group div label {
min-width: 3ch;
text-align: center;
font-size: 1rem;
}
section.radio-group div label:first-of-type {
border-top-left-radius: 0.25rem;
border-bottom-left-radius: 0.25rem;
}
section.radio-group div label:last-of-type {
border-top-right-radius: 0.25rem;
border-bottom-right-radius: 0.25rem;
}
section.radio-group div input:checked+label {
background: var(--primary);
color: var(--primary-contrast);
}
.documentation-dialog .range-selector {
display: flex;
gap: 1em;
flex-direction: row;
flex-wrap: wrap;
}
.documentation-dialog .range-selector section {
flex-grow: 1;
width: initial;
}
.documentation-dialog .hide {
display: none;
}
.admin-dashboard {
display: flex;
flex-direction: row;
flex-wrap: wrap;
box-sizing: border-box;
margin: 1em;
gap: 1em;
background: transparent;
width: calc(100% - 2em);
}
.admin-dashboard .filter-dialog {
margin: 0;
width: 100%;
flex-grow: 1;
}
.admin-dashboard .table-results table {
width: 100%;
}
.admin-dashboard .table-results table th, .admin-dashboard .table-results table td {
text-align: left;
margin: 0.4em 0;
}
.admin-dashboard .table-results table tr td:nth-child(3) {
display: flex;
flex-direction: row;
}
@media screen and (min-width: 480px) {
.admin-dashboard .filter-dialog {
width: 300px;
flex-grow: 0;
}
}
.edit-icon {
mask: url(/assets/icons/edit.svg) no-repeat;
mask-size: contain;
background: var(--color);
}
.delete-icon {
mask: url(/assets/icons/trash-delete.svg) no-repeat;
mask-size: contain;
background: var(--color);
}
img.icon, button.icon svg, span.icon {
height: 1.2em;
width: 1.2em;
}
button.icon, a.icon {
border: none;
cursor: pointer;
background: transparent;
font-size: 1rem;
display: flex;
align-content: baseline;
gap: 0.3em;
}
button.icon svg, button.icon svg * {
fill: var(--primary-contrast);
color: var(--primary-contrast);
stroke: var(--primary-contrast);
}
dialog {
border: 1px solid var(--dialog-border);
}
dialog::backdrop {
background: var(--dialog-backdrop);
}
dialog {
width: 400px;
max-width: 80vw;
box-sizing: border-box;
}
+17 -1
View File
@@ -1,6 +1,16 @@
const dialogContainerId = "#dialog-container" const dialogContainerId = "#dialog-container"
window.addEventListener('load', () => { window.addEventListener('load', () => {
document.querySelectorAll(".dropdown").forEach(el => {
const action = el.querySelector(".dropdown-action");
const content = el.querySelector(".dropdown-content");
action.addEventListener('click', () => {
content.classList.toggle('selected');
content.style.marginLeft = `-${(content.offsetWidth - action.offsetWidth)}px`;
})
})
// configure radio button groups // configure radio button groups
document.querySelectorAll("section.radio-group").forEach(el => { document.querySelectorAll("section.radio-group").forEach(el => {
const submittedInput = el.querySelector("input[type=hidden]"); const submittedInput = el.querySelector("input[type=hidden]");
@@ -23,7 +33,13 @@ window.addEventListener('load', () => {
let response = await fetch(btn.dataset.url); let response = await fetch(btn.dataset.url);
if(response.ok) { if(response.ok) {
response = await response.text(); 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; container.innerHTML = response;
const dialogReference = document.querySelector(selector); const dialogReference = document.querySelector(selector);
dialogReference?.showModal(); 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.
-276
View File
@@ -1,276 +0,0 @@
/* cleanup */
* {
box-sizing: border-box;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
padding: 0;
margin: 0;
}
header {
padding: 1rem 2rem;
width: 100%;
background: #666;
}
header h1 {
font-size: 1.25em;
}
header a {
color: #fff;
text-decoration: none;
}
nav {
padding: 1rem 2rem;
width: 100%;
background: #f7f7f7;
}
nav a, nav button {
display: inline-block;
text-decoration: none;
color: #333;
font-size: 1rem;
border: none;
background: none;
padding: 0.5rem 1rem;
transition: ease-out 0.2s;
}
nav a:hover, nav button:hover {
background: #e0e0e0;
color: #000;
transition: ease-out 0.2s;
}
nav a.selected, nav button.selected {
background: #216897;
color: white;
}
table:not(.clean) {
width: 100%;
}
table th, table td {
text-align: left;
}
table {
border-collapse: collapse;
}
table thead * {
background: #666;
color: #fff;
}
table th, table td {
padding: 0.4rem 0.4rem;
}
table:not(.clean) tbody tr:nth-child(2n+1) {
background: #f5f5f5;
}
table:not(.clean) tbody tr:nth-child(2n) {
background: #e5e5e5;
}
button {
padding: 0.25rem 1rem;
}
.card {
padding: 1em;
background: #fff;
border-radius: 0.2rem;
border: 1px solid #bbb;
}
.card .card-title {
margin-bottom: 0.5em;
}
.card p {
margin-bottom: 0.5em;
}
form section {
width: fit-content;
}
/* styling */
form section:not(.radio-group) label+:not(select) {
display: flex;
}
form section {
padding-bottom: 0.75rem;
}
form section > input {
width: 200px;
padding: 0.2em 0.5em;
}
form section > textarea {
width: 200px;
padding: 0.2em 0.5em;
}
form section select {
display: block;
width: 200px;
border: none;
font-size: 0.9rem;
padding: 0.2em 0.5em;
background: #ddd;
}
form section label.checkbox {
display: block;
overflow: hidden;
width: 200px;
}
form section input[type=checkbox] {
vertical-align: middle;
float: right;
}
section.radio-group input {
display: none;
}
section.radio-group div label {
display: inline-block;
padding: 0.25rem 0.75rem;
background: #ddd;
}
section.radio-group div label {
min-width: 64px;
text-align: center;
font-size: 0.9rem;
}
section.radio-group div label:first-of-type {
border-top-left-radius: 0.25rem;
border-bottom-left-radius: 0.25rem;
}
section.radio-group div label:last-of-type {
border-top-right-radius: 0.25rem;
border-bottom-right-radius: 0.25rem;
}
section.radio-group div input:checked+label {
background: #555;
color: white;
}
img.icon {
height: 1.5em;
width: 1.5em;
}
button.icon, a.icon {
display: flex;
align-content: baseline;
gap: 0.3em;
}
main {
display: flex;
flex-wrap: wrap;
max-width: 1280px;
margin: auto;
}
.container {
max-width: 1280px;
margin: auto;
}
section#search {
margin: 1em;
width: fit-content;
}
.index-page {
margin: 1em auto;
align-content: center;
gap: 1em;
}
.index-page section {
flex-grow: 1;
}
.index-page section article {
margin: auto;
width: 300px;
max-width: 90vw;
}
section#results {
margin: 1em;
flex-grow: 1;
}
button.clean {
display: inline-block;
padding: 0.4rem;
border: none;
background: none;
text-decoration: none;
color: #000;
cursor: pointer;
}
section#results a, section#results button {
display: inline-block;
padding: 0.4rem;
border: none;
background: none;
text-decoration: none;
color: #000;
cursor: pointer;
}
dialog {
position: fixed;
left: 50%;
top: 80px;
transform: translate(-50%, 0);
max-height: calc(100vh - 160px);
overflow-y: scroll;
}
section.actions {
padding: 0;
}
article.single-holiday {
padding-bottom: 0.5em;
margin-bottom: 1em;
border-bottom: 2px solid #a0a0a0;
}
.optional-selector {
border: 1px solid #666;
border-radius: 0.5em;
}
.optional-selector .header {
padding: 1em;
background: #666;
margin-bottom: 0.5em;
color: white;
border-top-left-radius: 0.5em;
border-top-right-radius: 0.5em;
}
.optional-selector > *:not(.header) {
margin: 0 1em;
}
.hide {
display: none;
}
.source-code {
padding: 1em;
}
.source-code, .source-code * {
background: #777;
color: #fff;
font-family: 'Courier New', Courier, monospace;
}
+2 -14
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
_ "github.com/lib/pq" _ "github.com/lib/pq"
"holiday-api/db"
"os" "os"
) )
@@ -22,18 +23,5 @@ func connectToDb() (*sqlx.DB, error) {
password := envMustExist("PSQL_PASSWORD") password := envMustExist("PSQL_PASSWORD")
dbname := envMustExist("PSQL_DB") dbname := envMustExist("PSQL_DB")
psqlInfo := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", return db.ConnectToDbNamed(host, port, user, password, dbname)
host, port, user, password, dbname)
db, err := sqlx.Open("postgres", psqlInfo)
if err != nil {
return nil, err
}
err = db.Ping()
if err != nil {
return nil, err
}
return db, nil
} }
+30
View File
@@ -0,0 +1,30 @@
package db
import (
"embed"
"fmt"
"github.com/jmoiron/sqlx"
)
//go:embed dev/*.sql
var DevMigrations embed.FS
//go:embed prod/*.sql
var ProdMigrations embed.FS
func ConnectToDbNamed(host string, port string, user string, password string, dbname string) (*sqlx.DB, error) {
psqlInfo := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
host, port, user, password, dbname)
db, err := sqlx.Open("postgres", psqlInfo)
if err != nil {
return nil, err
}
err = db.Ping()
if err != nil {
return nil, err
}
return db, nil
}
+2 -18
View File
@@ -5,7 +5,7 @@ CREATE TABLE IF NOT EXISTS "country"
name varchar(45) NOT NULL, name varchar(45) NOT NULL,
primary key (id) primary key (id)
); );
CREATE TABLE IF NOT EXISTS "holiday" CREATE TABLE IF NOT EXISTS "holiday"
( (
@@ -19,21 +19,5 @@ CREATE TABLE IF NOT EXISTS "holiday"
primary key (id), primary key (id),
constraint fk_country_id foreign key (country) constraint fk_country_id foreign key (country)
references co references country(iso_name) on delete cascade on update cascade
primary key (id)
);
CREATE TABLE IF NOT EXISTS "holiday"
(
id uuid,
country char(2) NOT NULL,
date date NOT NULL,
name varchar(64) NOT NULL,
description varchar(512),
is_state boolean NOT NULL,
is_religious boolean NOT NULL,
primary key (id),
constraint fk_country_id foreign key (cuntry(iso_name) on delete cascade on update cascade
); );
File renamed without changes.
+17 -48
View File
@@ -1,38 +1,15 @@
package main package holiday
import ( import (
"bytes"
"encoding/csv"
"encoding/xml" "encoding/xml"
"fmt" "fmt"
"github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
"holiday-api/holiday" "strconv"
"net/http"
"time" "time"
) )
func getHolidays(service holiday.HolidayService) gin.HandlerFunc {
return func(c *gin.Context) {
paging := holiday.Paging{PageSize: 50}
if err := c.ShouldBindQuery(&paging); err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
search := holiday.Search{}
if err := c.ShouldBindQuery(&search); err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
holidays, err := service.Find(search, paging)
if err != nil {
render(c, http.StatusNotFound, ErrorResponse{Created: time.Now(), Message: "failed fetching holidays"})
} else {
render(c, http.StatusOK, mapHolidays(holidays))
}
}
}
type DateResponse struct{ time.Time } type DateResponse struct{ time.Time }
func (d DateResponse) MarshalJSON() ([]byte, error) { func (d DateResponse) MarshalJSON() ([]byte, error) {
@@ -54,6 +31,18 @@ type HolidayResponse struct {
Holidays []HolidayItemResponse `json:"holidays"` 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 { type HolidayItemResponse struct {
XMLName xml.Name `json:"-" xml:"Holiday"` XMLName xml.Name `json:"-" xml:"Holiday"`
Id uuid.UUID `json:"id" xml:"id,attr"` Id uuid.UUID `json:"id" xml:"id,attr"`
@@ -64,27 +53,7 @@ type HolidayItemResponse struct {
IsReligiousHoliday bool `json:"isReligiousHoliday" xml:"isReligious,attr"` IsReligiousHoliday bool `json:"isReligiousHoliday" xml:"isReligious,attr"`
} }
type HolidaySingleResponse struct { func ToResponse(holidays []Holiday) HolidayResponse {
Id *uuid.UUID
Country string
Date DateResponse
Name string
Description string
IsStateHoliday bool
IsReligiousHoliday bool
}
type HolidaySingleRequest struct {
Id *uuid.UUID
Country string
Date DateResponse
Name string
Description string
IsStateHoliday bool
IsReligiousHoliday bool
}
func mapHolidays(holidays []holiday.Holiday) HolidayResponse {
var response = make([]HolidayItemResponse, 0, len(holidays)) var response = make([]HolidayItemResponse, 0, len(holidays))
for _, h := range holidays { for _, h := range holidays {
response = append(response, HolidayItemResponse{ response = append(response, HolidayItemResponse{
@@ -37,22 +37,22 @@ func (s *HolidayService) FindById(id uuid.UUID) (Holiday, error) {
func (s *HolidayService) findByDate(date time.Time, isState *bool, isReligious *bool, country string) ([]Holiday, error) { func (s *HolidayService) findByDate(date time.Time, isState *bool, isReligious *bool, country string) ([]Holiday, error) {
var holidays []Holiday var holidays []Holiday
return holidays, s.DB.Select(&holidays, `SELECT * FROM "holiday" WHERE "date" = $1 AND country = $2 `+s.filter(isState, isReligious)+";", date, country) return holidays, s.DB.Select(&holidays, `SELECT * FROM "holiday" WHERE "date" = $1 AND country = $2 `+s.filter(isState, isReligious)+" ORDER BY \"date\";", date, country)
} }
func (s *HolidayService) findForRange(rangeStart time.Time, rangeEnd time.Time, isState *bool, isReligious *bool, country string) ([]Holiday, error) { func (s *HolidayService) findForRange(rangeStart time.Time, rangeEnd time.Time, isState *bool, isReligious *bool, country string) ([]Holiday, error) {
var holidays []Holiday var holidays []Holiday
return holidays, s.DB.Select(&holidays, `SELECT * FROM "holiday" WHERE "date" BETWEEN $1 AND $2 AND country = $3`+s.filter(isState, isReligious)+";", rangeStart, rangeEnd, country) return holidays, s.DB.Select(&holidays, `SELECT * FROM "holiday" WHERE "date" BETWEEN $1 AND $2 AND country = $3`+s.filter(isState, isReligious)+" ORDER BY \"date\";", rangeStart, rangeEnd, country)
} }
func (s *HolidayService) findByYear(year int, isState *bool, isReligious *bool, country string) ([]Holiday, error) { func (s *HolidayService) findByYear(year int, isState *bool, isReligious *bool, country string) ([]Holiday, error) {
var holidays []Holiday var holidays []Holiday
return holidays, s.DB.Select(&holidays, `SELECT * FROM "holiday" WHERE extract(year from "date") = $1 AND country = $2 `+s.filter(isState, isReligious)+";", year, country) return holidays, s.DB.Select(&holidays, `SELECT * FROM "holiday" WHERE extract(year from "date") = $1 AND country = $2 `+s.filter(isState, isReligious)+" ORDER BY \"date\";", year, country)
} }
func (s *HolidayService) find(isState *bool, isReligious *bool, country string) ([]Holiday, error) { func (s *HolidayService) find(isState *bool, isReligious *bool, country string) ([]Holiday, error) {
var holidays []Holiday var holidays []Holiday
return holidays, s.DB.Select(&holidays, `SELECT * FROM "holiday" WHERE country = $1 `+s.filter(isState, isReligious)+";", country) return holidays, s.DB.Select(&holidays, `SELECT * FROM "holiday" WHERE country = $1 `+s.filter(isState, isReligious)+" ORDER BY \"date\";", country)
} }
func (s *HolidayService) paginate(holidays []Holiday, paging Paging) []Holiday { func (s *HolidayService) paginate(holidays []Holiday, paging Paging) []Holiday {
@@ -102,6 +102,23 @@ func (s *HolidayService) Delete(id uuid.UUID) error {
return err return err
} }
func (s *HolidayService) Copy(country string, from int, to int) error {
holidays, err := s.findByYear(from, nil, nil, country)
if err != nil {
return err
}
var diff = to - from
for _, holiday := range holidays {
holiday.Date = holiday.Date.AddDate(diff, 0, 0)
_, err := s.Create(holiday)
if err != nil {
return err
}
}
return nil
}
func getDateOrDefault(date *time.Time, year int) time.Time { func getDateOrDefault(date *time.Time, year int) time.Time {
if date == nil { if date == nil {
return time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC) return time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC)
@@ -34,6 +34,7 @@ type Search struct {
RangeEnd *time.Time `form:"range_end" time_format:"2006-01-02"` RangeEnd *time.Time `form:"range_end" time_format:"2006-01-02"`
StateHoliday string `form:"state_holiday,omitempty" binding:"omitempty"` StateHoliday string `form:"state_holiday,omitempty" binding:"omitempty"`
ReligiousHoliday string `form:"religious_holiday,omitempty" binding:"omitempty"` ReligiousHoliday string `form:"religious_holiday,omitempty" binding:"omitempty"`
Type *string `form:"type,omitempty" binding:"omitempty"`
} }
func (s Search) IsStateHoliday() *bool { func (s Search) IsStateHoliday() *bool {
File renamed without changes.
+16 -267
View File
@@ -1,302 +1,51 @@
package main package main
import ( import (
"embed"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/joho/godotenv" "github.com/joho/godotenv"
_ "github.com/lib/pq" _ "github.com/lib/pq"
"holiday-api/holiday" "holiday-api/api"
"holiday-api/db"
"holiday-api/migration" "holiday-api/migration"
"html/template" "io/fs"
"log" "log"
"net/http" "net/http"
"os" "os"
"strconv"
"strings" "strings"
"time"
) )
//go:embed db/dev/*.sql
var devMigrations embed.FS
//go:embed db/prod/*.sql
var prodMigrations embed.FS
var isDev = false
func init() { func init() {
godotenv.Load() godotenv.Load()
if strings.Contains(os.Getenv("PROFILE"), "dev") {
isDev = true
}
log.SetPrefix("") log.SetPrefix("")
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile) log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
} }
func main() { func main() {
client, err := connectToDb() db, err := connectToDb()
if err != nil { if err != nil {
log.Fatalf("couldn't connect to db: %v", err) log.Fatalf("couldn't connect to db: %v", err)
} }
migrationFolder := prodMigrations if err := migration.InitializeMigrations(db, migrationFolder()); err != nil {
if isDev {
migrationFolder = devMigrations
}
if err := migration.InitializeMigrations(client, migrationFolder); err != nil {
log.Fatalf("couldn't execute migrations: %v", err) log.Fatalf("couldn't execute migrations: %v", err)
} }
g := gin.Default() server := api.SetupServer(db)
g.Static("assets", "assets") log.Fatal(http.ListenAndServe(":5281", server))
loadTemplates(g)
holidayService := holiday.HolidayService{DB: client}
countryService := holiday.CountryService{DB: client}
yearService := holiday.YearService{DB: client}
g.GET("/api/v1/holidays", getHolidays(holidayService))
setupAdminDashboard(g.Group("/admin"), holidayService, countryService, yearService)
g.GET("/", func(c *gin.Context) {
year := time.Now().Year()
search := holiday.Search{Country: "HR", Year: &year}
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})
})
g.GET("/documentation", func(c *gin.Context) {
countries, _ := countryService.Find()
years, _ := yearService.Find()
c.HTML(http.StatusOK, "documentation.gohtml", gin.H{"Years": years, "Countries": countries})
})
g.GET("/search", func(c *gin.Context) {
request := holiday.Search{}
if err := c.ShouldBindQuery(&request); err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
search := holiday.Search{Country: request.Country, Date: request.Date}
holidays, _ := holidayService.Find(search, holiday.Paging{PageSize: 100})
countries, _ := countryService.Find()
c.HTML(http.StatusOK, "search.gohtml", gin.H{"Countries": countries, "Search": search, "Holidays": mapHolidays(holidays).Holidays})
})
g.GET("/dialogs/check-is-a-holiday", func(c *gin.Context) {
countries, _ := countryService.Find()
c.HTML(http.StatusOK, "check-is-a-holiday.gohtml", gin.H{"Countries": countries})
})
log.Fatal(http.ListenAndServe(":5281", g))
} }
func loadTemplates(g *gin.Engine) { func hasProfile(value string) bool {
g.SetFuncMap(template.FuncMap{ profileOptions := strings.Split(os.Getenv("PROFILE"), ",")
"boolcmp": func(value *bool, expected string) bool { for _, option := range profileOptions {
if value == nil { if option == value {
return expected == "nil" return true
} else {
return (*value && expected == "true") || (!(*value) && expected == "false")
} }
},
"deferint": func(value *int) int { return *value },
"intpeq": func(selected *int, value int) bool {
if selected != nil {
return *selected == value
} }
return false return false
},
})
g.LoadHTMLFiles(
"templates/index.gohtml",
"templates/search.gohtml",
"templates/documentation.gohtml",
"templates/admin_dashboard.gohtml",
"templates/countries.gohtml",
"templates/dialogs/add-holiday.gohtml",
"templates/dialogs/edit-holiday.gohtml",
"templates/dialogs/delete-holiday.gohtml",
"templates/dialogs/check-is-a-holiday.gohtml",
"templates/dialogs/edit-country.gohtml",
"templates/dialogs/delete-country.gohtml",
)
} }
func setupAdminDashboard(adminDashboard *gin.RouterGroup, service holiday.HolidayService, countryService holiday.CountryService, yearService holiday.YearService) { func migrationFolder() fs.FS {
adminDashboard.Use(gin.BasicAuth(loadAuth())) if hasProfile("dev") {
return db.DevMigrations
adminDashboard.GET("/", func(c *gin.Context) {
search := holiday.Search{Country: "HR", Year: new(int)}
*search.Year = time.Now().Year()
if err := c.ShouldBindQuery(&search); err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
} }
holidays, _ := service.Find(search, holiday.Paging{PageSize: 100}) return db.ProdMigrations
holidayResponse := mapHolidays(holidays)
countries, _ := countryService.Find()
years, _ := yearService.Find()
response := map[string]any{}
response["Holidays"] = holidayResponse
response["Search"] = search
response["Countries"] = countries
response["Years"] = years
c.HTML(http.StatusOK, "admin_dashboard.gohtml", response)
})
adminDashboard.POST("/holidays", func(c *gin.Context) {
request := struct {
Id *string `form:"id"`
Name string `form:"name" binding:"required,min=1"`
Description string `form:"description"`
IsStateHoliday bool `form:"state_holiday"`
IsReligiousHoliday bool `form:"religious_holiday"`
Country string `form:"country" binding:"len=2"`
Date time.Time `form:"date" time_format:"2006-01-02"`
}{}
if err := c.ShouldBind(&request); err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
hol := holiday.Holiday{
Country: request.Country,
Date: request.Date,
Name: request.Name,
Description: request.Description,
IsStateHoliday: request.IsStateHoliday,
IsReligiousHoliday: request.IsReligiousHoliday,
}
var err error
if request.Id != nil {
hol.Id = uuid.MustParse(*request.Id)
hol, err = service.Update(hol)
} else {
hol, err = service.Create(hol)
}
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
} else {
c.Redirect(http.StatusSeeOther, "/admin?country="+request.Country+"&year="+strconv.FormatInt(int64(request.Date.Year()), 10))
}
})
adminDashboard.POST("/holidays/:id/delete", func(c *gin.Context) {
id := uuid.MustParse(c.Param("id"))
hol, err := service.FindById(id)
if err != nil {
c.AbortWithError(http.StatusNotFound, err)
return
}
if err := service.Delete(id); err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.Redirect(http.StatusSeeOther, "/admin?country="+hol.Country+"&year="+strconv.FormatInt(int64(hol.Date.Year()), 10))
})
adminDashboard.GET("/countries", func(c *gin.Context) {
countries, _ := countryService.Find()
c.HTML(http.StatusOK, "countries.gohtml", gin.H{"Countries": countries})
})
adminDashboard.POST("/countries", func(c *gin.Context) {
request := struct {
Id *string `form:"id"`
IsoName string `form:"iso_name" binding:"required,min=2,max=2"`
Name string `form:"name" binding:"required,min=1,max=45"`
}{}
if err := c.ShouldBind(&request); err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
country := holiday.Country{
IsoName: request.IsoName,
Name: request.Name,
}
var err error
if request.Id != nil {
country.Id = uuid.MustParse(*request.Id)
country, err = countryService.Update(country)
} else {
country, err = countryService.Create(country)
}
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
} else {
c.Redirect(http.StatusSeeOther, "/admin/countries")
}
})
adminDashboard.POST("/countries/:id/delete", func(c *gin.Context) {
id := uuid.MustParse(c.Param("id"))
_, err := countryService.FindById(id)
if err != nil {
c.AbortWithError(http.StatusNotFound, err)
return
}
if err := countryService.Delete(id); err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.Redirect(http.StatusSeeOther, "/admin/countries")
})
adminDashboard.GET("/dialogs/add-holiday", func(c *gin.Context) {
countries, _ := countryService.Find()
c.HTML(http.StatusOK, "add-holiday.gohtml", gin.H{"Countries": countries})
})
adminDashboard.GET("/dialogs/edit-holiday", func(c *gin.Context) {
id := uuid.MustParse(c.Query("id"))
hol, err := service.FindById(id)
if err != nil {
c.AbortWithError(http.StatusNotFound, err)
return
}
countries, _ := countryService.Find()
c.HTML(http.StatusOK, "edit-holiday.gohtml", gin.H{"Countries": countries, "Holiday": hol})
})
adminDashboard.GET("/dialogs/edit-country", func(c *gin.Context) {
id := uuid.MustParse(c.Query("id"))
country, err := countryService.FindById(id)
if err != nil {
c.AbortWithError(http.StatusNotFound, err)
return
}
c.HTML(http.StatusOK, "edit-country.gohtml", gin.H{"Country": country})
})
adminDashboard.GET("/dialogs/delete-holiday", func(c *gin.Context) {
id := uuid.MustParse(c.Query("id"))
hol, err := service.FindById(id)
if err != nil {
c.AbortWithError(http.StatusNotFound, err)
return
}
c.HTML(http.StatusOK, "delete-holiday.gohtml", gin.H{"Holiday": hol})
})
adminDashboard.GET("/dialogs/delete-country", func(c *gin.Context) {
id := uuid.MustParse(c.Query("id"))
country, err := countryService.FindById(id)
if err != nil {
c.AbortWithError(http.StatusNotFound, err)
return
}
c.HTML(http.StatusOK, "delete-country.gohtml", gin.H{"Country": country})
})
}
func loadAuth() map[string]string {
credentials := envMustExist("AUTH_KEY")
values := strings.Split(credentials, ":")
return map[string]string{values[0]: values[1]}
} }
+1 -1
View File
@@ -1,7 +1,7 @@
# scripts for building app # scripts for building app
# requires go 1.19+ and git installed # requires go 1.19+ and git installed
VERSION := 0.2.3 VERSION := 1.0.0
serve: serve:
go run ./... go run ./...
-20
View File
@@ -1,20 +0,0 @@
package main
import (
"github.com/gin-gonic/gin"
)
func render[T any](c *gin.Context, status int, response T) {
switch c.GetHeader("accept") {
case "application/xml":
fallthrough
case "text/xml":
c.Header("content-type", c.GetHeader("accept")+"; charset=utf-8")
c.XML(status, response)
case "application/json":
fallthrough
default:
c.Header("content-type", "application/json; charset=utf-8")
c.JSON(status, response)
}
}
+48 -51
View File
@@ -1,81 +1,79 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html>
<head> <head>
<meta charset="UTF-8"> <title>Holiday-api | Admin</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta charset="utf-8">
<title>Holiday-api | Admin dashboard</title> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/assets/style.css">
<link rel="icon" href="/assets/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="/assets/global.css">
<script src="/assets/global.js"></script> <script src="/assets/global.js"></script>
</head> </head>
<body> <body>
<div id="dialog-container"></div> <div class="background-image" style="position: fixed">
</div>
<header> <header>
<section class="container"> <h1><a href="/admin?country=HR">Holiday-api | Admin</a></h1>
<h1><a href="/">Holiday-api | Admin dashboard</a></h1> <div style="flex-grow: 1"></div>
</section> <a style="all: unset;" href="/admin/countries">Države</a>
</header> </header>
<nav> <main class="admin-dashboard">
<section class="container"> <div class="filter-dialog" style="background: transparent">
<a class="selected" href="#">Search</a> <section class="dialog filter-dialog">
<a href="/admin/countries">Countries</a> <h2 style="margin-top: 0.5em">Pretraga</h2>
<button data-type="dialog" data-trigger="#create-card" data-url="/admin/dialogs/add-holiday">Add new holiday</button>
</section>
</nav>
<main>
<section id="search">
<article class="card">
<form action="#" method="get"> <form action="#" method="get">
<section> <section class="form-field">
<label for="country">Country:</label> <label for="country">Država:</label>
<select id="country" name="country"> <select id="country" name="country">
{{range $entry := .Countries}} {{range $entry := .Countries}}
<option {{if eq $.Search.Country $entry.IsoName}}selected{{end}} value="{{$entry.IsoName}}">{{$entry.Name}}</option> <option {{if eq $.Search.Country $entry.IsoName}}selected{{end}} value="{{$entry.IsoName}}">{{$entry.Name}}</option>
{{end}} {{end}}
</select> </select>
</section> </section>
<section> <section class="form-field">
<label for="year">Year:</label> <label for="year">Za godinu:</label>
<select id="year" name="year"> <select id="year" name="year">
{{range $entry := .Years}} {{range $entry := .Years}}
<option {{if intpeq $.Search.Year $entry}}selected{{end}} value="{{$entry}}">{{$entry}}</option> <option {{if intpeq $.Search.Year $entry}}selected{{end}} value="{{$entry}}">{{$entry}}</option>
{{end}} {{end}}
</select> </select>
</section> </section>
<section class="radio-group"> <section class="radio-group" style="margin-top: 0.5em;">
<label>Is state holiday:</label> <label>Državni praznik:</label>
<div> <div>
<input type="radio" value="true" name="sh" id="sh_true"><label for="sh_true">True <input type="radio" value="true" name="sh" id="sh_true"><label for="sh_true">Da
</label><input type="radio" value="false" name="sh" id="sh_false"><label for="sh_false">False </label><input type="radio" value="false" name="sh" id="sh_false"><label for="sh_false">Ne
</label><input type="radio" value="" name="sh" id="sh_any"><label for="sh_any">All</label> </label><input type="radio" value="" name="sh" id="sh_any"><label for="sh_any">Svi</label>
</div> </div>
<input type="hidden" value="{{.Search.StateHoliday}}" name="state_holiday"> <input type="hidden" value="{{.Search.StateHoliday}}" id="state-holiday" name="state_holiday">
</section> </section>
<section class="radio-group"> <section class="radio-group">
<label>Is religious holiday:</label> <label>Religiozni praznik:</label>
<div> <div>
<input type="radio" value="true" name="rh" id="rh_true"><label for="rh_true">True <input type="radio" value="true" name="rh" id="rh_true"><label for="rh_true">Da
</label><input type="radio" value="false" name="rh" id="rh_false"><label for="rh_false">False </label><input type="radio" value="false" name="rh" id="rh_false"><label for="rh_false">Ne
</label><input type="radio" value="" name="rh" id="rh_any"><label for="rh_any">All</label> </label><input type="radio" value="" name="rh" id="rh_any"><label for="rh_any">Svi</label>
</div> </div>
<input type="hidden" value="{{.Search.ReligiousHoliday}}" name="religious_holiday"> <input type="hidden" value="{{.Search.ReligiousHoliday}}" id="religious-holiday" 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> </section>
<div class="button-actions">
<button class="primary">Pretraži</button>
</div>
</form> </form>
</article>
</section> </section>
<section id="results"> </div>
<div style="flex-grow: 1; background: transparent">
<div style="display: flex; flex-direction: row; flex-wrap: wrap; justify-content: end; background: transparent; gap: 0.5em">
<button data-type="dialog" data-trigger="#copy-year" data-url="/admin/dialogs/copy-year?year={{$.Search.Year}}&country={{$.Search.Country}}" class="secondary">Kopiraj godinu</button>
<button data-type="dialog" data-trigger="#create-card" data-url="/admin/dialogs/add-holiday" class="primary">Dodaj novi praznik</button>
</div>
<section class="dialog table-results" style="margin: 0; margin-top: 0.5em" id="results">
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>Ime</th>
<th>Date</th> <th>Datum</th>
<th>State</th>
<th>Religious</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@@ -84,18 +82,17 @@
{{range $entry := .Holidays.Holidays}} {{range $entry := .Holidays.Holidays}}
<tr> <tr>
<td>{{$entry.Name}}</td> <td>{{$entry.Name}}</td>
<td>{{$entry.Date.Format "2006-01-02"}}</td> <td>{{$entry.Date.Format "02.01.2006"}}</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> <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="#update-card" data-url="/admin/dialogs/edit-holiday?id={{$entry.Id}}" class="icon"><span class="icon edit-icon"></span></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="#delete-card" data-url="/admin/dialogs/delete-holiday?id={{$entry.Id}}" class="icon"><span class="icon delete-icon"></span></button>
</td> </td>
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
</section> </section>
</div>
</main> </main>
</body> </body>
</html> </html>
+21 -31
View File
@@ -1,60 +1,50 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html>
<head> <head>
<meta charset="UTF-8"> <title>Holiday-api | Države</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta charset="utf-8">
<title>Holiday-api | Admin dashboard</title> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/assets/style.css">
<link rel="icon" href="/assets/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="/assets/global.css">
<script src="/assets/global.js"></script> <script src="/assets/global.js"></script>
</head> </head>
<body> <body>
<div id="dialog-container"></div> <div class="background-image" style="position: fixed">
</div>
<header> <header>
<section class="container"> <h1><a href="/admin?country=HR">Holiday-api | Države</a></h1>
<h1><a href="/">Holiday-api | Countries</a></h1>
</section>
</header> </header>
<nav> <main class="admin-dashboard">
<section class="container"> <div style="width: 640px; max-width: 100%; margin: auto; background: transparent">
<a href="/admin">Search</a> <div style="display: flex; flex-direction: row; flex-wrap: wrap; justify-content: end; background: transparent">
<a class="selected" href="#">Countries</a> <button style="float: right; margin-top: 0" data-type="dialog" data-trigger="#create-card" data-url="/admin/dialogs/add-country" class="primary">Dodaj državu</button>
<button data-type="dialog" data-trigger="#create-card" data-url="/admin/dialogs/add-holiday">Add new holiday</button> </div>
</section> <section class="dialog table-results" style="margin: 0; margin-top: 0.5em" id="results">
</nav>
<main>
<section style="margin: 1em; flex-grow: 1">
<form action="/admin/countries" method="post">
<table style="width: 100%"> <table style="width: 100%">
<thead> <thead>
<tr> <tr>
<th>Iso name</th> <th>Iso ime</th>
<th>Name</th> <th>Ime</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr>
<td><section><input minlength="2" maxlength="2" required id="iso_name" type="text" name="iso_name"></section></td>
<td><section><input minlength="1" maxlength="45" required id="name" type="text" name="name"></section></td>
<td><button>Create country</button></td>
</tr>
{{range $entry := .Countries}} {{range $entry := .Countries}}
<tr> <tr>
<td>{{$entry.IsoName}}</td> <td>{{$entry.IsoName}}</td>
<td>{{$entry.Name}}</td> <td>{{$entry.Name}}</td>
<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="#update-card" data-url="/admin/dialogs/edit-country?id={{$entry.Id}}" class="clean icon"><span class="icon edit-icon"></span></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="#delete-card" data-url="/admin/dialogs/delete-country?id={{$entry.Id}}" class="clean icon"><span class="icon delete-icon"></span></button>
</td> </td>
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
</form>
</section> </section>
</div>
</main> </main>
</body> </body>
</html> </html>
+17
View File
@@ -0,0 +1,17 @@
<dialog class="card" id="create-card">
<h3 class="card-title">Dodaj državu</h3>
<form method="post" action="/admin/countries">
<section class="form-field">
<label for="name">Iso ime:</label>
<input required minlength="2" maxlength="2" id="iso_name" value="{{.Country.IsoName}}" name="iso_name" type="text">
</section>
<section class="form-field">
<label for="name">Ime:</label>
<input required minlength="1" maxlength="45" id="name" value="{{.Country.Name}}" name="name" type="text">
</section>
<section class="actions">
<button class="primary" type="submit">Dodaj</button>
<button class="secondary" type="button" onclick="closeDialog('#create-card')">Odustani</button>
</section>
</form>
</dialog>
+15 -15
View File
@@ -1,35 +1,35 @@
<dialog class="card" id="create-card"> <dialog class="card" id="create-card">
<h3 class="card-title">Create holiday</h3> <h3 class="card-title">Dodaj praznik</h3>
<form method="post" action="/admin/holidays"> <form method="post" action="/admin/holidays">
<section> <section class="form-field">
<label for="country">Country:</label> <label for="country">Država:</label>
<select id="country" required name="country"> <select id="country" required name="country">
{{range $entry := .Countries}} {{range $entry := .Countries}}
<option value="{{$entry.IsoName}}">{{$entry.Name}}</option> <option value="{{$entry.IsoName}}">{{$entry.Name}}</option>
{{end}} {{end}}
</select> </select>
</section> </section>
<section> <section class="form-field">
<label for="name">Name:</label> <label for="name">Ime:</label>
<input required id="name" name="name" type="text"> <input required id="name" name="name" type="text">
</section> </section>
<section> <section class="form-field">
<label for="description">Description:</label> <label for="description">Opis:</label>
<textarea id="description" name="description"></textarea> <textarea id="description" name="description"></textarea>
</section> </section>
<section> <section class="form-field">
<label for="date">Date:</label> <label for="date">Datum:</label>
<input required id="date" name="date" type="date"> <input required id="date" name="date" type="date">
</section> </section>
<section> <section class="form-field">
<label class="checkbox"><span>State holiday</span><input id="state_holiday" value="true" name="state_holiday" type="checkbox"></label> <label class="checkbox"><span>Državni praznik</span><input id="state_holiday" value="true" name="state_holiday" type="checkbox"></label>
</section> </section>
<section> <section class="form-field">
<label class="checkbox"><span>Religious holiday</span><input id="religious_holiday" value="true" name="religious_holiday" type="checkbox"></label> <label class="checkbox"><span>Religiozni praznik</span><input id="religious_holiday" value="true" name="religious_holiday" type="checkbox"></label>
</section> </section>
<section class="actions"> <section class="actions">
<button type="submit">Create</button> <button class="primary" type="submit">Dodaj</button>
<button type="button" onclick="closeDialog('#create-card')">Cancel</button> <button class="secondary" type="button" onclick="closeDialog('#create-card')">Odustani</button>
</section> </section>
</form> </form>
</dialog> </dialog>
@@ -1,23 +0,0 @@
<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>
</div>
<form method="get" action="/search">
<section>
<label for="country">Country:</label>
<select id="country" name="country">
{{range $entry := .Countries}}
<option value="{{$entry.IsoName}}">{{$entry.Name}}</option>
{{end}}
</select>
</section>
<section>
<label for="date">Date:</label>
<input id="date" name="date" type="date" required>
</section>
<section class="actions">
<button style="width: 100%;" type="submit">Check is a holiday</button>
</section>
</form>
</dialog>
+18
View File
@@ -0,0 +1,18 @@
<dialog class="card" id="copy-year">
<h3 class="card-title">Kopiraj godinu {{.Year}}</h3>
<p>Na koji godinu želite kopirati praznike iz godine {{.Year}}?</p>
<form method="post" action="/admin/holidays/copy">
<input type="hidden" name="from" value="{{.Year}}">
<input type="hidden" name="country" value="{{.Country}}">
<section class="form-field">
<label for="to">Godina:</label>
<input required id="to" name="to" type="number" step="1">
</section>
<section class="actions">
<button class="primary" type="submit">Kopiraj</button>
<button class="secondary" type="button" onclick="closeDialog('#copy-year')">Odustani</button>
</section>
</form>
</dialog>
+4 -4
View File
@@ -1,11 +1,11 @@
<dialog class="card" id="delete-card"> <dialog class="card" id="delete-card">
<h3 class="card-title">Delete country</h3> <h3 class="card-title">Obriši državu</h3>
<p>Are you sure you want to delete "{{.Country.Name}}"?<br>All holidays for given country will be deleted!</p> <p>Jeste li sigurni da želite obrisati "{{.Country.Name}}"?<br>Svi uneseni praznici za danu državu će biti obrisani!</p>
<form method="post" action="/admin/countries/{{.Country.Id}}/delete"> <form method="post" action="/admin/countries/{{.Country.Id}}/delete">
<section class="actions"> <section class="actions">
<button type="submit">Delete</button> <button class="primary" type="submit">Obriši</button>
<button type="button" onclick="closeDialog('#delete-card')">Cancel</button> <button class="secondary" type="button" onclick="closeDialog('#delete-card')">Odustani</button>
</section> </section>
</form> </form>
</dialog> </dialog>
+4 -4
View File
@@ -1,11 +1,11 @@
<dialog class="card" id="delete-card"> <dialog class="card" id="delete-card">
<h3 class="card-title">Delete holiday</h3> <h3 class="card-title">Ukloni praznik</h3>
<p>Are you sure you want to delete "{{.Holiday.Name}}"?</p> <p>Jeste li sigurni da želite ukloniti praznik "{{.Holiday.Name}}"?</p>
<form method="post" action="/admin/holidays/{{.Holiday.Id}}/delete"> <form method="post" action="/admin/holidays/{{.Holiday.Id}}/delete">
<section class="actions"> <section class="actions">
<button type="submit">Delete</button> <button class="primary" type="submit">Obriši</button>
<button type="button" onclick="closeDialog('#delete-card')">Cancel</button> <button class="secondary" type="button" onclick="closeDialog('#delete-card')">Odustani</button>
</section> </section>
</form> </form>
</dialog> </dialog>
+7 -7
View File
@@ -1,18 +1,18 @@
<dialog class="card" id="update-card"> <dialog class="card" id="update-card">
<h3 class="card-title">Edit country</h3> <h3 class="card-title">Ažuriraj državu</h3>
<form method="post" action="/admin/countries"> <form method="post" action="/admin/countries">
<input type="hidden" name="id" value="{{.Country.Id}}"> <input type="hidden" name="id" value="{{.Country.Id}}">
<section> <section class="form-field">
<label for="name">Iso name:</label> <label for="name">Iso ime:</label>
<input required minlength="2" maxlength="2" id="iso_name" value="{{.Country.IsoName}}" name="iso_name" type="text"> <input required minlength="2" maxlength="2" id="iso_name" value="{{.Country.IsoName}}" name="iso_name" type="text">
</section> </section>
<section> <section class="form-field">
<label for="name">Name:</label> <label for="name">Ime:</label>
<input required minlength="1" maxlength="45" id="name" value="{{.Country.Name}}" name="name" type="text"> <input required minlength="1" maxlength="45" id="name" value="{{.Country.Name}}" name="name" type="text">
</section> </section>
<section class="actions"> <section class="actions">
<button type="submit">Update</button> <button class="primary" type="submit">Ažuriraj</button>
<button type="button" onclick="closeDialog('#update-card')">Cancel</button> <button class="secondary" type="button" onclick="closeDialog('#update-card')">Odustani</button>
</section> </section>
</form> </form>
</dialog> </dialog>
+15 -15
View File
@@ -1,36 +1,36 @@
<dialog class="card" id="update-card"> <dialog class="card" id="update-card">
<h3 class="card-title">Edit holiday</h3> <h3 class="card-title">Ažuriraj praznik</h3>
<form method="post" action="/admin/holidays"> <form method="post" action="/admin/holidays">
<input type="hidden" name="id" value="{{.Holiday.Id}}"> <input type="hidden" name="id" value="{{.Holiday.Id}}">
<section> <section class="form-field">
<label for="country">Country:</label> <label for="country">Država:</label>
<select id="country" required name="country"> <select id="country" required name="country">
{{range $entry := .Countries}} {{range $entry := .Countries}}
<option {{if eq $.Holiday.Country $entry.IsoName}}selected{{end}} value="{{$entry.IsoName}}">{{$entry.Name}}</option> <option {{if eq $.Holiday.Country $entry.IsoName}}selected{{end}} value="{{$entry.IsoName}}">{{$entry.Name}}</option>
{{end}} {{end}}
</select> </select>
</section> </section>
<section> <section class="form-field">
<label for="name">Name:</label> <label for="name">Ime:</label>
<input required id="name" value="{{.Holiday.Name}}" name="name" type="text"> <input required id="name" value="{{.Holiday.Name}}" name="name" type="text">
</section> </section>
<section> <section class="form-field">
<label for="description">Description:</label> <label for="description">Opis:</label>
<textarea id="description" name="description">{{.Holiday.Description}}</textarea> <textarea id="description" name="description">{{.Holiday.Description}}</textarea>
</section> </section>
<section> <section class="form-field">
<label for="date">Date:</label> <label for="date">Datum:</label>
<input required id="date" value="{{.Holiday.Date.Format "2006-01-02"}}" name="date" type="date"> <input required id="date" value="{{.Holiday.Date.Format "2006-01-02"}}" name="date" type="date">
</section> </section>
<section> <section class="form-field">
<label class="checkbox"><span>State holiday</span><input id="state_holiday" value="true" {{if .Holiday.IsStateHoliday}}checked{{end}} name="state_holiday" type="checkbox"></label> <label class="checkbox"><span>Državni praznik</span><input id="state_holiday" value="true" {{if .Holiday.IsStateHoliday}}checked{{end}} name="state_holiday" type="checkbox"></label>
</section> </section>
<section> <section class="form-field">
<label class="checkbox"><span>Religious holiday</span><input id="religious_holiday" value="true" {{if .Holiday.IsReligiousHoliday}}checked{{end}} name="religious_holiday" type="checkbox"></label> <label class="checkbox"><span>Religiozni praznik</span><input id="religious_holiday" value="true" {{if .Holiday.IsReligiousHoliday}}checked{{end}} name="religious_holiday" type="checkbox"></label>
</section> </section>
<section class="actions"> <section class="actions">
<button type="submit">Update</button> <button class="primary" type="submit">Ažuriraj</button>
<button type="button" onclick="closeDialog('#update-card')">Cancel</button> <button class="secondary" type="button" onclick="closeDialog('#update-card')">Odustani</button>
</section> </section>
</form> </form>
</dialog> </dialog>
+64 -67
View File
@@ -1,115 +1,105 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="hr">
<head> <head>
<meta charset="UTF-8"> <title>Holiday-api | Api dokumentacija</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta charset="utf-8">
<title>Holiday-api</title> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/assets/style.css">
<meta name="description" content="Preuzmi praznike za neki vremenski raspon programski u JSON, XML ili CSV formatu"/>
<link rel="icon" href="/assets/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="/assets/global.css">
<script src="/assets/global.js"></script> <script src="/assets/global.js"></script>
<script src="/assets/documentation.js"></script> <script src="/assets/documentation.js"></script>
<style>
.dropdown-content a {
all: unset;
display: block;
padding: 0.5em 1em;
}
.dropdown-content a:hover {
background: rgba(95, 158, 160, 0.2);
}
</style>
</head> </head>
<body> <body>
<div id="dialog-container"></div> <div class="background-image" style="position: fixed;"></div>
<header> <header>
<section class="container"> <h1><a href="/">Holiday-api</a> | Dokumentacija</h1>
<h1><a href="/">Holiday-api</a></h1>
</section>
</header> </header>
<nav> <main class="dialog documentation-dialog">
<section class="container"> <section style="margin-bottom: 1em;">
<a href="/">Search</a>
<a class="selected" href="#">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="query"> <section id="query">
<article class="card" style="margin: 1em;"> <article class="card" style="margin: 1em;">
<form id="query-generator"> <form id="query-generator">
<section> <input type="hidden" id="country" name="country" value="HR">
<label for="country">Country</label> <section class="form-field">
<select id="country" name="country"> <label for="content-type">Format</label>
{{range $entry := .Countries}}
<option value="{{$entry.IsoName}}">{{$entry.Name}}</option>
{{end}}
</select>
</section>
<section>
<label for="content-type">Content type</label>
<select id="content-type" name="content-type"> <select id="content-type" name="content-type">
<option value="application/json">JSON</option> <option value="json">JSON</option>
<option value="application/xml">XML</option> <option value="xml">XML</option>
<option value="text/csv">CSV</option> <option value="csv">CSV</option>
</select> </select>
</section> </section>
<article class="optional-selector"> <article class="optional-selector">
<div class="header"> <div class="header">
<section> <section class="form-field">
<label for="date-selector">Date selector</label> <label for="date-selector">Izbornik</label>
<select data-type="section-selector" data-selector-prefix="date-selector" id="date-selector" name="date-selector"> <select data-type="section-selector" data-selector-prefix="date-selector" id="date-selector" name="date-selector">
<option value="year">Select year</option> <option value="year">Za godinu</option>
<option value="date">Select date</option> <option value="date">Za datum</option>
<option value="range">Select date range</option> <option value="range">Za raspon datuma</option>
<option value="all">All</option> <option value="all">Svi</option>
</select> </select>
</section> </section>
</div> </div>
<div data-section-id="date-selector-year"> <div data-section-id="date-selector-year">
<section> <section class="form-field">
<label for="dsy-year">Year</label> <label for="dsy-year">Godina</label>
<select id="dsy-year" name="year"> <select id="dsy-year" name="year">
{{range $entry := .Years}} {{range $entry := .Years}}
<option value="{{$entry}}">{{$entry}}</option> <option {{if eq $.Year $entry}}selected{{end}} value="{{$entry}}">{{$entry}}</option>
{{end}} {{end}}
</select> </select>
</section> </section>
</div> </div>
<div data-section-id="date-selector-date"> <div data-section-id="date-selector-date">
<section> <section class="form-field">
<label for="dsd-date">Date</label> <label for="dsd-date">Datum</label>
<input type="date" id="dsd-date" name="dsd-date"> <input type="date" id="dsd-date" name="dsd-date">
</section> </section>
</div> </div>
<div data-section-id="date-selector-range"> <div class="range-selector" data-section-id="date-selector-range">
<section> <section class="form-field">
<label for="dsr-start-range">Start range</label> <label for="dsr-start-range">Početak raspona</label>
<input type="date" id="dsr-start-range" name="dsr-start-range"> <input type="date" id="dsr-start-range" name="dsr-start-range">
</section> </section>
<section> <section class="form-field">
<label for="dsr-start-range-required">Required</label> <label for="dsr-end-range">Kraj raspona</label>
<input type="checkbox" id="dsr-start-range-required" name="dsr-start-range-required">
</section>
<section>
<label for="dsr-end-range">End range</label>
<input type="date" id="dsr-end-range" name="dsr-end-range"> <input type="date" id="dsr-end-range" name="dsr-end-range">
</section> </section>
<section>
<label for="dsr-end-range-required">Required</label>
<input type="checkbox" id="dsr-end-range-required" name="dsr-end-range-required">
</section>
</div> </div>
</article> </article>
<section class="radio-group" style="margin-top: 0.5em;"> <section class="radio-group" style="margin-top: 0.5em;">
<label>Is state holiday:</label> <label>Državni praznik:</label>
<div> <div>
<input type="radio" value="true" name="sh" id="sh_true"><label for="sh_true">True <input type="radio" value="true" name="sh" id="sh_true"><label for="sh_true">Da
</label><input type="radio" value="false" name="sh" id="sh_false"><label for="sh_false">False </label><input type="radio" value="false" name="sh" id="sh_false"><label for="sh_false">Ne
</label><input type="radio" value="" name="sh" id="sh_any"><label for="sh_any">All</label> </label><input type="radio" value="" name="sh" id="sh_any"><label for="sh_any">Svi</label>
</div> </div>
<input type="hidden" id="state-holiday" name="state_holiday"> <input type="hidden" id="state-holiday" name="state_holiday">
</section> </section>
<section class="radio-group"> <section class="radio-group">
<label>Is religious holiday:</label> <label>Religiozni praznik:</label>
<div> <div>
<input type="radio" value="true" name="rh" id="rh_true"><label for="rh_true">True <input type="radio" value="true" name="rh" id="rh_true"><label for="rh_true">Da
</label><input type="radio" value="false" name="rh" id="rh_false"><label for="rh_false">False </label><input type="radio" value="false" name="rh" id="rh_false"><label for="rh_false">Ne
</label><input type="radio" value="" name="rh" id="rh_any"><label for="rh_any">All</label> </label><input type="radio" value="" name="rh" id="rh_any"><label for="rh_any">Svi</label>
</div> </div>
<input type="hidden" id="religious-holiday" name="religious_holiday"> <input type="hidden" id="religious-holiday" name="religious_holiday">
</section> </section>
@@ -117,11 +107,18 @@
</article> </article>
</section> </section>
<section id="result" style="flex-grow: 1; margin: 1em;"> <section id="result" style="flex-grow: 1; margin: 1em;">
<h3 style="margin-bottom: 1em;">Query</h3> <div style="display: flex; flex-wrap: wrap; align-items: baseline">
<article id="result-content" class="card source-code"> <h3 style="margin-bottom: 1em;">Naredba</h3>
Loading... <div style="flex-grow: 1"></div>
<div>
<a id="query-link" style="text-decoration: none" class="button secondary" href="." target="_blank">Dohvati</a>
</div>
</div>
<article id="result-content">
Učitavanje...
</article> </article>
</section> </section>
</section>
</main> </main>
</body> </body>
</html> </html>
+23 -80
View File
@@ -1,95 +1,38 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="hr">
<head> <head>
<meta charset="UTF-8"> <title>Holiday-api | Pronađi praznike</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta charset="utf-8">
<title>Holiday-api</title> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/assets/style.css">
<meta name="description" content="Pronađi listu praznike za neki vremenski raspon, ili kao web stranicu ili programski koristeći naš api u JSON, XML ili CSV formatu"/>
<link rel="icon" href="/assets/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="/assets/global.css">
<script src="/assets/global.js"></script> <script src="/assets/global.js"></script>
</head> </head>
<body> <body>
<div id="dialog-container"></div> <div class="background-image">
<header> <header>
<section class="container">
<h1><a href="/">Holiday-api</a></h1> <h1><a href="/">Holiday-api</a></h1>
</section> <div style="flex-grow: 1"></div>
</header> <a style="all: unset;" href="/documentation">Api dokumentacija</a>
<nav> </header>
<section class="container"> <main class="dialog search-dialog">
<a class="selected" href="#">Search</a> <form action="/">
<a href="/documentation">Documentation</a> <div class="form-field">
<button data-type="dialog" data-trigger="#check-is-a-holiday" data-url="/dialogs/check-is-a-holiday">Check is a holiday</button> <label for="year">Za godinu:</label>
</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"> <select id="year" name="year">
{{range $entry := .Years}} {{range $entry := .Years}}
<option {{if intpeq $.Search.Year $entry}}selected{{end}} value="{{$entry}}">{{$entry}}</option> <option {{if eq $.Year $entry}}selected{{end}} value="{{$entry}}">{{$entry}}</option>
{{end}} {{end}}
</select> </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> </div>
<input type="hidden" value="{{.Search.StateHoliday}}" name="state_holiday"> <div class="button-actions">
</section> <button class="primary">Pretraži</button>
<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> </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> </form>
</article> </main>
</section> </div>
<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>
</body> </body>
</html> </html>
+94
View File
@@ -0,0 +1,94 @@
<!DOCTYPE html>
<html lang="hr">
<head>
<title>Holiday-api | Praznici za {{deferint $.Search.Year}}</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Lista praznika za godinu {{deferint $.Search.Year}}. Praznici ovo ili neku drugu godinu pronađi ovdje. "/>
<link rel="icon" href="/assets/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: var(--dropdown-background);
min-width: 160px;
box-shadow: 0 8px 16px 0 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>
<header>
<h1><a href="/">Holiday-api</a> | {{deferint $.Search.Year}}</h1>
<div style="flex-grow: 1"></div>
<a style="all: unset;" href="/documentation">Api dokumentacija</a>
</header>
<main class="dialog results-dialog">
<section style="margin-bottom: 1em;">
<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>
</main>
</body>
</html>
-76
View File
@@ -1,76 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Holiday-api | Admin dashboard</title>
<link rel="stylesheet" href="/assets/style.css">
<script src="/assets/global.js"></script>
</head>
<body>
<div id="dialog-container"></div>
<header>
<section class="container">
<h1><a href="/">Holiday-api | {{.Search.Date.Format "2006-01-02"}}</a></h1>
</section>
</header>
<nav>
<section class="container">
<a href="/">Search</a>
<a href="/documentation">Documentation</a>
<a class="selected" href="#">For date</a>
</section>
</nav>
<main>
<section id="search">
<article class="card">
<h2 style="margin-right: 1em;">Is it a holiday?</h2>
<form method="get" action="/search">
<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="date">Date:</label>
<input id="date" value="{{.Search.Date.Format "2006-01-02"}}" name="date" type="date" required>
</section>
<section class="actions">
<button style="width: 100%;" type="submit">Check is a holiday</button>
</section>
</form>
</article>
</section>
<section id="results">
<h2 style="margin-bottom: 1em;">Results</h2>
{{range $entry := .Holidays}}
<article class="single-holiday">
<table class="clean">
<tbody>
<tr>
<th>Name: </th>
<td>{{$entry.Name}}</td>
</tr>
<tr>
<th>Description: </th>
<td>{{$entry.Description}}</td>
</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>
</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>
</tr>
</tbody>
</table>
</article>
{{end}}
</section>
</main>
</body>
</html>