Compare commits
16 Commits
feature/1_
...
main
Author | SHA1 | Date |
---|---|---|
Borna Rajković | b2646e5aa1 | |
Borna Rajković | b4288b2abb | |
brajkovic | 67c72e8b4f | |
Borna Rajković | c6f365ab28 | |
brajkovic | 2b61f9e0b1 | |
Borna Rajković | 45b220c69f | |
Borna Rajković | 8fbdffc965 | |
Borna Rajković | 76ef62bb96 | |
Borna Rajković | 6bd0c9e37c | |
Borna Rajković | 0173086939 | |
Borna Rajković | 9bdb5dd129 | |
Borna Rajković | 6163c0a04f | |
Borna Rajković | 7a64afd767 | |
Borna Rajković | af2d0f03c6 | |
Borna Rajković | ef526e6ff4 | |
brajkovic | 99c5e5cd13 |
|
@ -1,3 +1,5 @@
|
||||||
.idea/**
|
.idea/**
|
||||||
holiday-api
|
holiday-api
|
||||||
.env
|
.env
|
||||||
|
**/.DS_Store
|
||||||
|
.env*
|
43
README.md
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
Simple api used for tracking holidays
|
Simple api used for tracking holidays
|
||||||
|
|
||||||
|
To check out 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
|
||||||
|
@ -26,25 +28,25 @@ That endpoint accepts a list of required and optional parameters
|
||||||
- if defined year and rangeStart|rangeEnd parameters are ignored
|
- if defined year and rangeStart|rangeEnd parameters are ignored
|
||||||
|
|
||||||
|
|
||||||
`rangeStart|rangeEnd`
|
`range_start|range_end`
|
||||||
- returns holidays in given range with both ends being inclusive
|
- returns holidays in given range with both ends being inclusive
|
||||||
- if either limit isn't defined it is assumed to be up to or all from given limit (if rangeStart isn't defined all holidays before rangeEnd are returned and vice-verse)
|
- if either limit isn't defined it is assumed to be up to or all from given limit (if rangeStart isn't defined all holidays before rangeEnd are returned and vice-verse)
|
||||||
- dates must be formatted in ISO 8601 format [more info here](https://www.iso.org/iso-8601-date-and-time-format.html)
|
- dates must be formatted in ISO 8601 format [more info here](https://www.iso.org/iso-8601-date-and-time-format.html)
|
||||||
- eg. `rangeStart=2021-12-25&rangeEnd=2023-01-23`, `rangeStart=2023-01-20`
|
- eg. `range_start=2021-12-25&range_end=2023-01-23`, `range_start=2023-01-20`
|
||||||
- if defined year parameter is ignored
|
- if defined year parameter is ignored
|
||||||
|
|
||||||
`stateHoliday`
|
`state_holiday`
|
||||||
- if set true only holidays that are tagged as state holidays are returned, similar for if set false, if not set all holidays are returned
|
- if set true only holidays that are tagged as state holidays are returned, similar for if set false, if not set all holidays are returned
|
||||||
- eg. `stateHoliday=true`, `stateHoliday=false`
|
- eg. `state_holiday=true`, `state_holiday=false`
|
||||||
|
|
||||||
`religiousHoliday`
|
`religious_holiday`
|
||||||
- if set true only holidays that are tagged as religious holidays are returned, similar for if set false, if not set all holidays are returned
|
- if set true only holidays that are tagged as religious holidays are returned, similar for if set false, if not set all holidays are returned
|
||||||
- eg. `religiousHoliday=true`, `religiousHoliday=false`
|
- eg. `religious_holiday=true`, `religious_holiday=false`
|
||||||
|
|
||||||
#### Paging
|
#### Paging
|
||||||
`pageSize`
|
`page_size`
|
||||||
- returns at most pageSize number of holidays
|
- returns at most pageSize number of holidays
|
||||||
- eg. `pageSize=20`
|
- eg. `page_size=20`
|
||||||
- only applied if page is defined as well, by default set to 20
|
- only applied if page is defined as well, by default set to 20
|
||||||
|
|
||||||
`page`
|
`page`
|
||||||
|
@ -55,7 +57,8 @@ 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;
|
||||||
|
@ -63,10 +66,12 @@ By default, responses are returned as a json array
|
||||||
isStateHoliday: boolean;
|
isStateHoliday: boolean;
|
||||||
isReligiousHoliday: boolean;
|
isReligiousHoliday: boolean;
|
||||||
},...]
|
},...]
|
||||||
|
}
|
||||||
```
|
```
|
||||||
eg.
|
e.g.
|
||||||
```
|
```
|
||||||
[{
|
{
|
||||||
|
"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",
|
||||||
|
@ -74,6 +79,7 @@ eg.
|
||||||
"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)
|
||||||
|
@ -94,3 +100,18 @@ CSV Response
|
||||||
id,date,name,description,is_state_holiday,is_religious_holiday
|
id,date,name,description,is_state_holiday,is_religious_holiday
|
||||||
74a2a769-abf2-45d4-bdc4-442bbcc89138,2023-12-25,Christmas,TBD,true,true
|
74a2a769-abf2-45d4-bdc4-442bbcc89138,2023-12-25,Christmas,TBD,true,true
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
To start server few environment variables need to be set up. This can be done by creating `.env` file with following content
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PSQL_HOST=localhost
|
||||||
|
PSQL_PORT=5432
|
||||||
|
PSQL_USER=holiday
|
||||||
|
PSQL_PASSWORD=holidayPassword
|
||||||
|
PSQL_DB=holiday
|
||||||
|
|
||||||
|
PROFILE=dev,basic-auth
|
||||||
|
AUTH_KEY=holiday:holidayPassword
|
||||||
|
```
|
|
@ -0,0 +1,350 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"holiday-api/domain/holiday"
|
||||||
|
"log/slog"
|
||||||
|
"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) {
|
||||||
|
slog.Error(message, slog.String("err", errorMessage(err)))
|
||||||
|
c.AbortWithError(statusCode, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func errorMessage(err error) string {
|
||||||
|
if err != nil {
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
return "-"
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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())
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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",
|
||||||
|
)
|
||||||
|
}
|
After Width: | Height: | Size: 633 KiB |
After Width: | Height: | Size: 236 KiB |
|
@ -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");
|
||||||
|
|
||||||
|
@ -48,11 +44,12 @@ window.addEventListener('load', () => {
|
||||||
query['country'] = country.value;
|
query['country'] = country.value;
|
||||||
query['contentType'] = contentType.value;
|
query['contentType'] = contentType.value;
|
||||||
if(stateHoliday.value === 'true' || stateHoliday.value === 'false') {
|
if(stateHoliday.value === 'true' || stateHoliday.value === 'false') {
|
||||||
query['stateHoliday'] = parseBoolean(stateHoliday.value);
|
query['state_holiday'] = parseBoolean(stateHoliday.value);
|
||||||
}
|
}
|
||||||
if(religiousHoliday.value === 'true' || religiousHoliday.value === 'false') {
|
if(religiousHoliday.value === 'true' || religiousHoliday.value === 'false') {
|
||||||
query['religiousHoliday'] = parseBoolean(religiousHoliday.value);
|
query['religious_holiday'] = 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,11 +60,11 @@ window.addEventListener('load', () => {
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'range':
|
case 'range':
|
||||||
if(dStartRangeRequired.checked && dStartRange.value) {
|
if(dStartRange.value) {
|
||||||
query['startRange'] = dStartRange.value;
|
query['range_start'] = dStartRange.value;
|
||||||
}
|
}
|
||||||
if(dEndRangeRequired.checked && dEndRange.value) {
|
if(dEndRange.value) {
|
||||||
query['endRange'] = dEndRange.value;
|
query['range_end'] = dEndRange.value;
|
||||||
}
|
}
|
||||||
case 'all':
|
case 'all':
|
||||||
default:
|
default:
|
||||||
|
@ -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['state_holiday'] = parseBoolean(stateHoliday.value);
|
||||||
}
|
}
|
||||||
if(religiousHoliday.value === 'true' || religiousHoliday.value === 'false') {
|
if(religiousHoliday.value === 'true' || religiousHoliday.value === 'false') {
|
||||||
query['religiousHoliday'] = parseBoolean(religiousHoliday.value);
|
query['religious_holiday'] = 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,11 +94,11 @@ window.addEventListener('load', () => {
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'range':
|
case 'range':
|
||||||
if(dStartRangeRequired.checked && dStartRange.value) {
|
if(dStartRange.value) {
|
||||||
query['startRange'] = dStartRange.value;
|
query['range_start'] = dStartRange.value;
|
||||||
}
|
}
|
||||||
if(dEndRangeRequired.checked && dEndRange.value) {
|
if(dEndRange.value) {
|
||||||
query['endRange'] = dEndRange.value;
|
query['range_end'] = dEndRange.value;
|
||||||
}
|
}
|
||||||
case 'all':
|
case 'all':
|
||||||
default:
|
default:
|
||||||
|
|
After Width: | Height: | Size: 766 B |
|
@ -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;
|
||||||
|
}
|
|
@ -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();
|
||||||
|
|
Before Width: | Height: | Size: 795 B After Width: | Height: | Size: 791 B |
Before Width: | Height: | Size: 807 B After Width: | Height: | Size: 804 B |
Before Width: | Height: | Size: 602 B After Width: | Height: | Size: 599 B |
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
Before Width: | Height: | Size: 696 B After Width: | Height: | Size: 693 B |
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
@ -1,7 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
|
||||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.55281 1.60553C7.10941 1.32725 7.77344 1 9 1C10.2265 1 10.8906 1.32722 11.4472 1.6055L11.4631 1.61347C11.8987 1.83131 12.2359 1.99991 13 1.99993C14.2371 1.99998 14.9698 1.53871 15.2141 1.35512C15.5944 1.06932 16.0437 1.09342 16.3539 1.2369C16.6681 1.38223 17 1.72899 17 2.24148L17 13H20C21.6562 13 23 14.3415 23 15.999V19C23 19.925 22.7659 20.6852 22.3633 21.2891C21.9649 21.8867 21.4408 22.2726 20.9472 22.5194C20.4575 22.7643 19.9799 22.8817 19.6331 22.9395C19.4249 22.9742 19.2116 23.0004 19 23H5C4.07502 23 3.3148 22.7659 2.71092 22.3633C2.11331 21.9649 1.72739 21.4408 1.48057 20.9472C1.23572 20.4575 1.11827 19.9799 1.06048 19.6332C1.03119 19.4574 1.01616 19.3088 1.0084 19.2002C1.00194 19.1097 1.00003 19.0561 1 19V2.24146C1 1.72899 1.33184 1.38223 1.64606 1.2369C1.95628 1.09341 2.40561 1.06931 2.78589 1.35509C3.03019 1.53868 3.76289 1.99993 5 1.99993C5.76415 1.99993 6.10128 1.83134 6.53688 1.6135L6.55281 1.60553ZM3.00332 19L3 3.68371C3.54018 3.86577 4.20732 3.99993 5 3.99993C6.22656 3.99993 6.89059 3.67269 7.44719 3.39441L7.46312 3.38644C7.89872 3.1686 8.23585 3 9 3C9.76417 3 10.1013 3.16859 10.5369 3.38643L10.5528 3.39439C11.1094 3.67266 11.7734 3.9999 13 3.99993C13.7927 3.99996 14.4598 3.86581 15 3.68373V19C15 19.783 15.1678 20.448 15.4635 21H5C4.42498 21 4.0602 20.8591 3.82033 20.6992C3.57419 20.5351 3.39761 20.3092 3.26943 20.0528C3.13928 19.7925 3.06923 19.5201 3.03327 19.3044C3.01637 19.2029 3.00612 19.1024 3.00332 19ZM19.3044 20.9667C19.5201 20.9308 19.7925 20.8607 20.0528 20.7306C20.3092 20.6024 20.5351 20.4258 20.6992 20.1797C20.8591 19.9398 21 19.575 21 19V15.999C21 15.4474 20.5529 15 20 15H17L17 19C17 19.575 17.1409 19.9398 17.3008 20.1797C17.4649 20.4258 17.6908 20.6024 17.9472 20.7306C18.2075 20.8607 18.4799 20.9308 18.6957 20.9667C18.8012 20.9843 18.8869 20.9927 18.9423 20.9967C19.0629 21.0053 19.1857 20.9865 19.3044 20.9667Z" fill="#0F0F0F"/>
|
|
||||||
<path d="M5 8C5 7.44772 5.44772 7 6 7H12C12.5523 7 13 7.44772 13 8C13 8.55229 12.5523 9 12 9H6C5.44772 9 5 8.55229 5 8Z" fill="#0F0F0F"/>
|
|
||||||
<path d="M5 12C5 11.4477 5.44772 11 6 11H12C12.5523 11 13 11.4477 13 12C13 12.5523 12.5523 13 12 13H6C5.44772 13 5 12.5523 5 12Z" fill="#0F0F0F"/>
|
|
||||||
<path d="M5 16C5 15.4477 5.44772 15 6 15H12C12.5523 15 13 15.4477 13 16C13 16.5523 12.5523 17 12 17H6C5.44772 17 5 16.5523 5 16Z" fill="#0F0F0F"/>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 2.5 KiB |
|
@ -1,4 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
|
||||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.46484 3.92349C4.79896 3.5739 4 4.05683 4 4.80888V19.1911C4 19.9432 4.79896 20.4261 5.46483 20.0765L19.1622 12.8854C19.8758 12.5108 19.8758 11.4892 19.1622 11.1146L5.46484 3.92349ZM2 4.80888C2 2.55271 4.3969 1.10395 6.39451 2.15269L20.0919 9.34382C22.2326 10.4677 22.2325 13.5324 20.0919 14.6562L6.3945 21.8473C4.39689 22.8961 2 21.4473 2 19.1911V4.80888Z" fill="#0F0F0F"/>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 652 B |
276
assets/style.css
|
@ -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;
|
|
||||||
}
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
### v1.0.1
|
||||||
|
* Updated api documentation page
|
||||||
|
* Updated README.md
|
26
db.go
|
@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -15,25 +16,22 @@ func envMustExist(env string) string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func envOrDefault(env string, defaultValue string) string {
|
||||||
|
if value, exists := os.LookupEnv(env); exists {
|
||||||
|
return value
|
||||||
|
} else {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func connectToDb() (*sqlx.DB, error) {
|
func connectToDb() (*sqlx.DB, error) {
|
||||||
host := envMustExist("PSQL_HOST")
|
host := envMustExist("PSQL_HOST")
|
||||||
port := envMustExist("PSQL_PORT")
|
port := envMustExist("PSQL_PORT")
|
||||||
user := envMustExist("PSQL_USER")
|
user := envMustExist("PSQL_USER")
|
||||||
password := envMustExist("PSQL_PASSWORD")
|
password := envMustExist("PSQL_PASSWORD")
|
||||||
dbname := envMustExist("PSQL_DB")
|
dbname := envMustExist("PSQL_DB")
|
||||||
|
sslMode := envOrDefault("PSQL_SSLMODE", "disable")
|
||||||
|
schema := envOrDefault("PSQL_SCHEMA", "public")
|
||||||
|
|
||||||
psqlInfo := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
return db.ConnectToDbNamed(host, port, user, password, dbname, sslMode, schema)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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, sslMode string, schema string) (*sqlx.DB, error) {
|
||||||
|
psqlInfo := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s search_path=%s",
|
||||||
|
host, port, user, password, dbname, sslMode, schema)
|
||||||
|
|
||||||
|
db, err := sqlx.Open("postgres", psqlInfo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.Ping()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return db, nil
|
||||||
|
}
|
|
@ -22,36 +22,29 @@ CREATE TABLE IF NOT EXISTS "holiday"
|
||||||
references country(iso_name) on delete cascade on update cascade
|
references country(iso_name) on delete cascade on update cascade
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS "webhook"
|
INSERT INTO "country" (id, iso_name, name)
|
||||||
(
|
VALUES
|
||||||
id uuid,
|
('096ca6c4-5c04-47a4-0063-4b4cc6f6b671', 'HR', 'Croatia'),
|
||||||
created timestamp NOT NULL,
|
('096ca6c4-5c04-47a4-0063-4b4cc6f6b672', 'US', 'USA'),
|
||||||
url varchar(256) NOT NULL,
|
('096ca6c4-5c04-47a4-0063-4b4cc6f6b673', 'FR', 'France'),
|
||||||
country char(2) NOT NULL,
|
('096ca6c4-5c04-47a4-0063-4b4cc6f6b674', 'GB', 'Great Britain');
|
||||||
retry_count int NOT NULL,
|
|
||||||
on_created bool NOT NULL,
|
|
||||||
on_edited bool NOT NULL,
|
|
||||||
on_deleted bool NOT NULL,
|
|
||||||
|
|
||||||
primary key (id),
|
INSERT INTO "holiday" (id, country, date, name, description, is_state, is_religious)
|
||||||
constraint fk_country_id foreign key (country)
|
VALUES
|
||||||
references country(iso_name) on delete cascade on update cascade
|
('096ca6c4-5c04-47a4-b363-4b4cc6f6b671', 'HR', '2023-01-01', 'Nova godina', '', true, false),
|
||||||
);
|
('096ca6c4-5c04-47a4-b363-4b4cc6f6b672', 'HR', '2023-01-06', 'Sveta tri kralja', '', true, true),
|
||||||
|
('096ca6c4-5c04-47a4-b363-4b4cc6f6b673', 'HR', '2023-04-09', 'Uskrs', '', true, true),
|
||||||
|
('096ca6c4-5c04-47a4-b363-4b4cc6f6b674', 'HR', '2023-04-10', 'Uskrsni ponedjeljak', '', true, true),
|
||||||
|
('096ca6c4-5c04-47a4-b363-4b4cc6f6b675', 'HR', '2023-05-01', 'Praznik rada', '', true, false),
|
||||||
|
('096ca6c4-5c04-47a4-b363-4b4cc6f6b676', 'HR', '2023-05-30', 'Dan državnosti', '', true, false),
|
||||||
|
('096ca6c4-5c04-47a4-b363-4b4cc6f6b677', 'HR', '2023-06-08', 'Tijelovo', '', true, true),
|
||||||
|
('096ca6c4-5c04-47a4-b363-4b4cc6f6b678', 'HR', '2023-06-22', 'Dan antifašističke borbe', '', true, false),
|
||||||
|
('096ca6c4-5c04-47a4-b363-4b4cc6f6b679', 'HR', '2023-08-05', 'Dan pobjede i domovinske zahvalnost', '', true, false),
|
||||||
|
('096ca6c4-5c04-47a4-b363-4b4cc6f6b680', 'HR', '2023-08-15', 'Velika gospa', '', true, true),
|
||||||
|
('096ca6c4-5c04-47a4-b363-4b4cc6f6b681', 'HR', '2023-11-01', 'Dan svih svetih', '', true, true),
|
||||||
|
('096ca6c4-5c04-47a4-b363-4b4cc6f6b682', 'HR', '2023-11-18', 'Dan sjećanja na žrtve Domovinskog rata', '', true, false),
|
||||||
|
('096ca6c4-5c04-47a4-b363-4b4cc6f6b683', 'HR', '2023-12-25', 'Božić', '', true, true),
|
||||||
|
('096ca6c4-5c04-47a4-b363-4b4cc6f6b684', 'HR', '2023-12-26', 'Štefanje', '', true, true);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS "job"
|
|
||||||
(
|
|
||||||
id uuid,
|
|
||||||
webhook_id uuid NOT NULL,
|
|
||||||
|
|
||||||
created timestamp NOT NULL,
|
|
||||||
success bool NOT NULL,
|
|
||||||
success_time timestamp,
|
|
||||||
|
|
||||||
retry_count int NOT NULL,
|
|
||||||
|
|
||||||
content varchar(1024) NOT NULL,
|
|
||||||
|
|
||||||
primary key (id),
|
|
||||||
constraint fk_webhook_id foreign key (webhook_id)
|
|
||||||
references webhook(id) on delete cascade on update cascade
|
|
||||||
);
|
|
|
@ -1,33 +0,0 @@
|
||||||
|
|
||||||
INSERT INTO "country" (id, iso_name, name)
|
|
||||||
VALUES
|
|
||||||
('096ca6c4-5c04-47a4-0063-4b4cc6f6b671', 'HR', 'Croatia'),
|
|
||||||
('096ca6c4-5c04-47a4-0063-4b4cc6f6b672', 'US', 'USA'),
|
|
||||||
('096ca6c4-5c04-47a4-0063-4b4cc6f6b673', 'FR', 'France'),
|
|
||||||
('096ca6c4-5c04-47a4-0063-4b4cc6f6b674', 'GB', 'Great Britain');
|
|
||||||
|
|
||||||
INSERT INTO "holiday" (id, country, date, name, description, is_state, is_religious)
|
|
||||||
VALUES
|
|
||||||
('096ca6c4-5c04-47a4-b363-4b4cc6f6b671', 'HR', '2023-01-01', 'Nova godina', '', true, false),
|
|
||||||
('096ca6c4-5c04-47a4-b363-4b4cc6f6b672', 'HR', '2023-01-06', 'Sveta tri kralja', '', true, true),
|
|
||||||
('096ca6c4-5c04-47a4-b363-4b4cc6f6b673', 'HR', '2023-04-09', 'Uskrs', '', true, true),
|
|
||||||
('096ca6c4-5c04-47a4-b363-4b4cc6f6b674', 'HR', '2023-04-10', 'Uskrsni ponedjeljak', '', true, true),
|
|
||||||
('096ca6c4-5c04-47a4-b363-4b4cc6f6b675', 'HR', '2023-05-01', 'Praznik rada', '', true, false),
|
|
||||||
('096ca6c4-5c04-47a4-b363-4b4cc6f6b676', 'HR', '2023-05-30', 'Dan državnosti', '', true, false),
|
|
||||||
('096ca6c4-5c04-47a4-b363-4b4cc6f6b677', 'HR', '2023-06-08', 'Tijelovo', '', true, true),
|
|
||||||
('096ca6c4-5c04-47a4-b363-4b4cc6f6b678', 'HR', '2023-06-22', 'Dan antifašističke borbe', '', true, false),
|
|
||||||
('096ca6c4-5c04-47a4-b363-4b4cc6f6b679', 'HR', '2023-08-05', 'Dan pobjede i domovinske zahvalnost', '', true, false),
|
|
||||||
('096ca6c4-5c04-47a4-b363-4b4cc6f6b680', 'HR', '2023-08-15', 'Velika gospa', '', true, true),
|
|
||||||
('096ca6c4-5c04-47a4-b363-4b4cc6f6b681', 'HR', '2023-11-01', 'Dan svih svetih', '', true, true),
|
|
||||||
('096ca6c4-5c04-47a4-b363-4b4cc6f6b682', 'HR', '2023-11-18', 'Dan sjećanja na žrtve Domovinskog rata', '', true, false),
|
|
||||||
('096ca6c4-5c04-47a4-b363-4b4cc6f6b683', 'HR', '2023-12-25', 'Božić', '', true, true),
|
|
||||||
('096ca6c4-5c04-47a4-b363-4b4cc6f6b684', 'HR', '2023-12-26', 'Štefanje', '', true, true);
|
|
||||||
|
|
||||||
INSERT INTO "webhook" (id, created, url, country, retry_count, on_created, on_edited, on_deleted)
|
|
||||||
VALUES
|
|
||||||
('b0b0a6c4-5c04-47a4-b363-4b4cc6f6b671', current_timestamp, 'https://hr.bbr-dev.info', 'HR', 10, true, true, true),
|
|
||||||
('b0b0a6c4-5c04-47a4-b363-4b4cc6f6b672', current_timestamp, 'https://hr2.bbr-dev.info', 'HR', 10, true, false, false);
|
|
||||||
|
|
||||||
INSERT INTO "job" (id, webhook_id, created, success, success_time, retry_count, content)
|
|
||||||
VALUES
|
|
||||||
('c0c0a6c4-5c04-47a4-b363-4b4cc6f6b671', 'b0b0a6c4-5c04-47a4-b363-4b4cc6f6b671', current_timestamp, false, null, 8, '{"type": "created", "holiday": {"id": "096ca6c4-5c04-47a4-b363-4b4cc6f6b671", "name": "Nova godina", "date": "2023-01-01"}}');
|
|
|
@ -18,6 +18,6 @@ CREATE TABLE IF NOT EXISTS "holiday"
|
||||||
is_religious boolean NOT NULL,
|
is_religious boolean NOT NULL,
|
||||||
|
|
||||||
primary key (id),
|
primary key (id),
|
||||||
constraint fk_country_id
|
constraint fk_country_id foreign key (country)
|
||||||
foreign key (cuntry(iso_name) on delete cascade on update cascade
|
references country(iso_name) on delete cascade on update cascade
|
||||||
);
|
);
|
|
@ -1,34 +0,0 @@
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS "webhook"
|
|
||||||
(
|
|
||||||
id uuid,
|
|
||||||
created timestamp NOT NULL,
|
|
||||||
url varchar(256) NOT NULL,
|
|
||||||
country char(2) NOT NULL,
|
|
||||||
retry_count int NOT NULL,
|
|
||||||
on_created bool NOT NULL,
|
|
||||||
on_edited bool NOT NULL,
|
|
||||||
on_deleted bool NOT NULL,
|
|
||||||
|
|
||||||
primary key (id),
|
|
||||||
constraint fk_country_id foreign key (country)
|
|
||||||
references country(iso_name) on delete cascade on update cascade
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS "job"
|
|
||||||
(
|
|
||||||
id uuid,
|
|
||||||
webhook_id uuid NOT NULL,
|
|
||||||
|
|
||||||
created timestamp NOT NULL,
|
|
||||||
success bool NOT NULL,
|
|
||||||
success_time timestamp NOT NULL,
|
|
||||||
|
|
||||||
retry_count int NOT NULL,
|
|
||||||
|
|
||||||
content varchar(1024) NOT NULL,
|
|
||||||
|
|
||||||
primary key (id),
|
|
||||||
constraint fk_webhook_id foreign key (webhook_id)
|
|
||||||
references webhook(id) on delete cascade on update cascade
|
|
||||||
);
|
|
|
@ -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 {
|
4
go.mod
|
@ -1,10 +1,10 @@
|
||||||
module holiday-api
|
module holiday-api
|
||||||
|
|
||||||
go 1.19
|
go 1.22
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gin-gonic/gin v1.9.1
|
github.com/gin-gonic/gin v1.9.1
|
||||||
github.com/google/uuid v1.3.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/jmoiron/sqlx v1.3.5
|
github.com/jmoiron/sqlx v1.3.5
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/lib/pq v1.10.9
|
github.com/lib/pq v1.10.9
|
||||||
|
|
2
go.sum
|
@ -34,6 +34,8 @@ github.com/google/gofuzz v1.0.0 h1:A8PeW59pxE9IoFRqBp37U+mSNaQoZ46F1f0f863XSXw=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=
|
github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=
|
||||||
github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
|
github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
|
||||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
|
411
main.go
|
@ -1,398 +1,89 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
|
||||||
"github.com/gin-gonic/gin"
|
"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"
|
||||||
"holiday-api/webhook"
|
"io/fs"
|
||||||
"html/template"
|
"log/slog"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"runtime/debug"
|
||||||
"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") {
|
if !hasProfile("dev") {
|
||||||
isDev = true
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
}
|
||||||
|
if value := os.Getenv("LOG_FORMAT"); value == "json" {
|
||||||
|
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{AddSource: true})))
|
||||||
}
|
}
|
||||||
log.SetPrefix("")
|
|
||||||
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
commit, time := buildInfo()
|
||||||
|
slog.Info("build info", slog.String("commit", commit), slog.String("time", time))
|
||||||
|
|
||||||
client, err := connectToDb()
|
client, err := connectToDb()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("couldn't connect to db: %v", err)
|
slog.Error("couldn't connect to client", slog.String("err", err.Error()))
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
migrationFolder := prodMigrations
|
if err := migration.InitializeMigrations(client, migrationFolder()); err != nil {
|
||||||
if isDev {
|
slog.Error("couldn't finish migration", slog.String("err", err.Error()))
|
||||||
migrationFolder = devMigrations
|
os.Exit(1)
|
||||||
}
|
|
||||||
if err := migration.InitializeMigrations(client, migrationFolder); err != nil {
|
|
||||||
log.Fatalf("couldn't execute migrations: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
g := gin.Default()
|
server := api.SetupServer(client)
|
||||||
|
|
||||||
g.Static("assets", "assets")
|
port := ":" + getOrDefault("SERVER_PORT", "5281")
|
||||||
|
slog.Info("app is ready", slog.String("port", port))
|
||||||
loadTemplates(g)
|
if err := http.ListenAndServe(port, server); err != nil {
|
||||||
|
slog.Error("Couldn't start server!\n", slog.Any("err", err.Error()))
|
||||||
holidayService := holiday.HolidayService{DB: client}
|
}
|
||||||
countryService := holiday.CountryService{DB: client}
|
|
||||||
yearService := holiday.YearService{DB: client}
|
|
||||||
webhookService := webhook.WebhookService{
|
|
||||||
DB: client,
|
|
||||||
Events: make(chan webhook.Event, 10),
|
|
||||||
Authorization: "TODO", // TODO add authorization fetching
|
|
||||||
}
|
}
|
||||||
|
|
||||||
g.GET("/api/v1/holidays", getHolidays(holidayService))
|
func hasProfile(value string) bool {
|
||||||
|
profileOptions := strings.Split(os.Getenv("PROFILE"), ",")
|
||||||
setupAdminDashboard(g.Group("/admin"), holidayService, countryService, yearService, webhookService)
|
for _, option := range profileOptions {
|
||||||
|
if option == value {
|
||||||
g.GET("/", func(c *gin.Context) {
|
return true
|
||||||
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) {
|
|
||||||
g.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
|
return false
|
||||||
},
|
|
||||||
})
|
|
||||||
g.LoadHTMLFiles(
|
|
||||||
"templates/index.gohtml",
|
|
||||||
"templates/search.gohtml",
|
|
||||||
"templates/documentation.gohtml",
|
|
||||||
|
|
||||||
"templates/admin_dashboard.gohtml",
|
|
||||||
"templates/countries.gohtml",
|
|
||||||
"templates/webhooks.gohtml",
|
|
||||||
"templates/jobs.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/add-webhook.gohtml",
|
|
||||||
"templates/dialogs/edit-webhook.gohtml",
|
|
||||||
"templates/dialogs/delete-webhook.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, webhookService webhook.WebhookService) {
|
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{
|
func buildInfo() (string, string) {
|
||||||
Country: request.Country,
|
revision := ""
|
||||||
Date: request.Date,
|
time := ""
|
||||||
Name: request.Name,
|
|
||||||
Description: request.Description,
|
if info, ok := debug.ReadBuildInfo(); ok {
|
||||||
IsStateHoliday: request.IsStateHoliday,
|
for _, setting := range info.Settings {
|
||||||
IsReligiousHoliday: request.IsReligiousHoliday,
|
if setting.Key == "vcs.revision" {
|
||||||
|
revision = setting.Value
|
||||||
|
} else if setting.Key == "vcs.time" {
|
||||||
|
time = setting.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return revision, time
|
||||||
}
|
}
|
||||||
|
|
||||||
var err error
|
func getOrDefault(env string, defaultValue string) string {
|
||||||
if request.Id != nil {
|
if value, present := os.LookupEnv(env); present {
|
||||||
hol.Id = uuid.MustParse(*request.Id)
|
return value
|
||||||
hol, err = service.Update(hol)
|
|
||||||
} else {
|
|
||||||
hol, err = service.Create(hol)
|
|
||||||
}
|
}
|
||||||
if err != nil {
|
return defaultValue
|
||||||
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("/webhooks", func(c *gin.Context) {
|
|
||||||
webhooks, _ := webhookService.Find()
|
|
||||||
c.HTML(http.StatusOK, "webhooks.gohtml", gin.H{"Webhooks": webhooks})
|
|
||||||
})
|
|
||||||
adminDashboard.GET("/jobs", func(c *gin.Context) {
|
|
||||||
jobs, _ := webhookService.FindAllJobs()
|
|
||||||
c.HTML(http.StatusOK, "jobs.gohtml", gin.H{"Jobs": jobs})
|
|
||||||
})
|
|
||||||
adminDashboard.POST("/webhooks", func(c *gin.Context) {
|
|
||||||
request := struct {
|
|
||||||
Id *string `form:"id"`
|
|
||||||
Url string `form:"url" binding:"required,min=1"`
|
|
||||||
RetryCount int `form:"retry_count" binding:"required,min=1,max=20"`
|
|
||||||
Country string `form:"country" binding:"len=2"`
|
|
||||||
OnCreated bool `form:"on_created"`
|
|
||||||
OnEdited bool `form:"on_edited"`
|
|
||||||
OnDeleted bool `form:"on_deleted"`
|
|
||||||
}{}
|
|
||||||
if err := c.ShouldBind(&request); err != nil {
|
|
||||||
c.AbortWithError(http.StatusBadRequest, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hook := webhook.Webhook{
|
|
||||||
Url: request.Url,
|
|
||||||
Country: request.Country,
|
|
||||||
RetryCount: request.RetryCount,
|
|
||||||
OnCreated: request.OnCreated,
|
|
||||||
OnEdited: request.OnEdited,
|
|
||||||
OnDeleted: request.OnDeleted,
|
|
||||||
}
|
|
||||||
|
|
||||||
var err error
|
|
||||||
if request.Id != nil {
|
|
||||||
hook.Id = uuid.MustParse(*request.Id)
|
|
||||||
hook, err = webhookService.Update(hook)
|
|
||||||
} else {
|
|
||||||
hook.Id = uuid.Must(uuid.NewRandom())
|
|
||||||
hook.Created = time.Now()
|
|
||||||
hook, err = webhookService.Create(hook)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
c.AbortWithError(http.StatusInternalServerError, err)
|
|
||||||
} else {
|
|
||||||
c.Redirect(http.StatusSeeOther, "/admin/webhooks")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
adminDashboard.POST("/webhooks/:id/delete", func(c *gin.Context) {
|
|
||||||
id := uuid.MustParse(c.Param("id"))
|
|
||||||
_, err := webhookService.FindById(id)
|
|
||||||
if err != nil {
|
|
||||||
c.AbortWithError(http.StatusNotFound, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := webhookService.Delete(id); err != nil {
|
|
||||||
c.AbortWithError(http.StatusInternalServerError, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Redirect(http.StatusSeeOther, "/admin/webhooks")
|
|
||||||
})
|
|
||||||
|
|
||||||
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/add-webhook", func(c *gin.Context) {
|
|
||||||
countries, _ := countryService.Find()
|
|
||||||
c.HTML(http.StatusOK, "add-webhook.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-webhook", func(c *gin.Context) {
|
|
||||||
id := uuid.MustParse(c.Query("id"))
|
|
||||||
hook, err := webhookService.FindById(id)
|
|
||||||
if err != nil {
|
|
||||||
c.AbortWithError(http.StatusNotFound, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
countries, _ := countryService.Find()
|
|
||||||
c.HTML(http.StatusOK, "edit-webhook.gohtml", gin.H{"Countries": countries, "Webhook": hook})
|
|
||||||
})
|
|
||||||
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})
|
|
||||||
})
|
|
||||||
adminDashboard.GET("/dialogs/delete-webhook", func(c *gin.Context) {
|
|
||||||
id := uuid.MustParse(c.Query("id"))
|
|
||||||
hook, err := webhookService.FindById(id)
|
|
||||||
if err != nil {
|
|
||||||
c.AbortWithError(http.StatusNotFound, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.HTML(http.StatusOK, "delete-webhook.gohtml", gin.H{"Webhook": hook})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadAuth() map[string]string {
|
|
||||||
credentials := envMustExist("AUTH_KEY")
|
|
||||||
values := strings.Split(credentials, ":")
|
|
||||||
return map[string]string{values[0]: values[1]}
|
|
||||||
}
|
}
|
||||||
|
|
20
makefile
|
@ -1,19 +1,11 @@
|
||||||
# scripts for building app
|
# scripts for building app
|
||||||
# requires go 1.19+ and git installed
|
# requires go 1.22+ and git installed
|
||||||
|
|
||||||
VERSION := 0.2.3
|
VERSION := $(shell git describe --tags --always)
|
||||||
|
|
||||||
serve:
|
|
||||||
go run ./...
|
|
||||||
|
|
||||||
setup:
|
|
||||||
go get
|
|
||||||
|
|
||||||
docker-dev:
|
docker-dev:
|
||||||
docker image build -t registry.bbr-dev.info/holiday-api/backend:$(VERSION)-dev .
|
docker image build -t registry.bbr-dev.info/holiday-api/backend/dev:latest .
|
||||||
docker tag registry.bbr-dev.info/holiday-api/backend:$(VERSION)-dev registry.bbr-dev.info/holiday-api/backend:latest-dev
|
docker image push registry.bbr-dev.info/holiday-api/backend/dev:latest
|
||||||
docker image push registry.bbr-dev.info/holiday-api/backend:$(VERSION)-dev
|
|
||||||
docker image push registry.bbr-dev.info/holiday-api/backend:latest-dev
|
|
||||||
|
|
||||||
|
|
||||||
docker-prod:
|
docker-prod:
|
||||||
|
@ -22,10 +14,6 @@ docker-prod:
|
||||||
docker image push registry.bbr-dev.info/holiday-api/backend:$(VERSION)
|
docker image push registry.bbr-dev.info/holiday-api/backend:$(VERSION)
|
||||||
docker image push registry.bbr-dev.info/holiday-api/backend:latest
|
docker image push registry.bbr-dev.info/holiday-api/backend:latest
|
||||||
|
|
||||||
release:
|
|
||||||
git tag $(VERSION)
|
|
||||||
git push origin $(VERSION)
|
|
||||||
|
|
||||||
test:
|
test:
|
||||||
go test ./...
|
go test ./...
|
||||||
|
|
||||||
|
|
|
@ -4,10 +4,11 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log/slog"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
@ -92,19 +93,21 @@ func validateMigrations(db *sqlx.DB, migrations map[string]Migration, migrationF
|
||||||
}
|
}
|
||||||
|
|
||||||
func executeMigration(db *sqlx.DB, name string, script string) error {
|
func executeMigration(db *sqlx.DB, name string, script string) error {
|
||||||
log.Printf("[INFO] script='%s' | migrations - executing", name)
|
logger := slog.Default().With(slog.String("script", name))
|
||||||
|
logger.Info("migrations - executing")
|
||||||
tx := db.MustBeginTx(context.Background(), nil)
|
tx := db.MustBeginTx(context.Background(), nil)
|
||||||
var err error = nil
|
var err error = nil
|
||||||
if _, e := tx.Exec(script); e != nil {
|
if _, e := tx.Exec(script); e != nil {
|
||||||
err = e
|
err = e
|
||||||
} else if _, e := tx.Exec(createMigration, name, hash(script)); e != nil {
|
}
|
||||||
|
if _, e := tx.Exec(createMigration, name, hash(script)); e != nil {
|
||||||
err = e
|
err = e
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[ERROR] script='%s' | migrations - failed executing", name)
|
logger.Error("migrations - failed executing", slog.String("err", err.Error()))
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
} else {
|
} else {
|
||||||
log.Printf("[INFO] script='%s' | migrations - succesfully executed", name)
|
logger.Info("migrations - successfully executed")
|
||||||
tx.Commit()
|
tx.Commit()
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
|
@ -118,9 +121,9 @@ func validateMigration(name string, migration Migration, script string) error {
|
||||||
calculatedHash := hash(script)
|
calculatedHash := hash(script)
|
||||||
|
|
||||||
if calculatedHash != migration.Hash {
|
if calculatedHash != migration.Hash {
|
||||||
err := fmt.Sprintf("migrations - mismatch in hash for %s (expected '%s', calculated '%s')", name, migration.Hash, calculatedHash)
|
err := errors.New(fmt.Sprintf("migrations - mismatch in hash for %s (expected '%s', calculated '%s')", name, migration.Hash, calculatedHash))
|
||||||
log.Printf("[ERROR] script='%s' err='%s' | migrations - failed executing", script, err)
|
slog.Error("migrations - failed validation", slog.String("script", name), slog.String("err", err.Error()))
|
||||||
return fmt.Errorf("migrations - mismatch in hashes for %s", name)
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
20
render.go
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,82 +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>
|
||||||
<a href="/admin/webhooks">Webhooks</a>
|
|
||||||
<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>
|
||||||
|
@ -85,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>
|
|
@ -1,61 +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>
|
||||||
<a href="/admin/webhooks">Webhooks</a>
|
</div>
|
||||||
<button data-type="dialog" data-trigger="#create-card" data-url="/admin/dialogs/add-holiday">Add new holiday</button>
|
<section class="dialog table-results" style="margin: 0; margin-top: 0.5em" id="results">
|
||||||
</section>
|
|
||||||
</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>
|
|
@ -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>
|
|
@ -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,35 +0,0 @@
|
||||||
<dialog class="card" id="create-card">
|
|
||||||
<h3 class="card-title">Create webhook</h3>
|
|
||||||
<form method="post" action="/admin/webhooks">
|
|
||||||
<section>
|
|
||||||
<label for="url">Url:</label>
|
|
||||||
<input required id="url" name="url" type="url">
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<label for="country">Country:</label>
|
|
||||||
<select id="country" required name="country">
|
|
||||||
{{range $entry := .Countries}}
|
|
||||||
<option value="{{$entry.IsoName}}">{{$entry.Name}}</option>
|
|
||||||
{{end}}
|
|
||||||
</select>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<label for="retry_count">Retry count:</label>
|
|
||||||
<input required id="retry_count" name="retry_count" type="number" min="0" max="10">
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<label class="checkbox"><span>On created</span><input id="on_created" value="true" name="on_created" type="checkbox"></label>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<label class="checkbox"><span>On edited</span><input id="on_edited" value="true" name="on_edited" type="checkbox"></label>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<label class="checkbox"><span>On deleted</span><input id="on_deleted" value="true" name="on_deleted" type="checkbox"></label>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="actions">
|
|
||||||
<button type="submit">Create</button>
|
|
||||||
<button type="button" onclick="closeDialog('#create-card')">Cancel</button>
|
|
||||||
</section>
|
|
||||||
</form>
|
|
||||||
</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>
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -1,11 +0,0 @@
|
||||||
|
|
||||||
<dialog class="card" id="delete-card">
|
|
||||||
<h3 class="card-title">Delete country</h3>
|
|
||||||
<p>Are you sure you want to delete job?</p>
|
|
||||||
<form method="post" action="/admin//{{.Country.Id}}/delete">
|
|
||||||
<section class="actions">
|
|
||||||
<button type="submit">Delete</button>
|
|
||||||
<button type="button" onclick="closeDialog('#delete-card')">Cancel</button>
|
|
||||||
</section>
|
|
||||||
</form>
|
|
||||||
</dialog>
|
|
|
@ -1,11 +0,0 @@
|
||||||
|
|
||||||
<dialog class="card" id="delete-card">
|
|
||||||
<h3 class="card-title">Delete webhook</h3>
|
|
||||||
<p>Are you sure you want to delete webhook to <br><b>{{.Webhook.Url}}?</b><br>All jobs for given webhook will be deleted!</p>
|
|
||||||
<form method="post" action="/admin/webhooks/{{.Webhook.Id}}/delete">
|
|
||||||
<section class="actions">
|
|
||||||
<button type="submit">Delete</button>
|
|
||||||
<button type="button" onclick="closeDialog('#delete-card')">Cancel</button>
|
|
||||||
</section>
|
|
||||||
</form>
|
|
||||||
</dialog>
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -1,36 +0,0 @@
|
||||||
<dialog class="card" id="update-card">
|
|
||||||
<h3 class="card-title">Edit webhook</h3>
|
|
||||||
<form method="post" action="/admin/webhooks">
|
|
||||||
<input type="hidden" id="id" name="id" value="{{.Webhook.Id}}">
|
|
||||||
<section>
|
|
||||||
<label for="url">Url:</label>
|
|
||||||
<input required id="url" name="url" type="url" value="{{.Webhook.Url}}">
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<label for="country">Country:</label>
|
|
||||||
<select id="country" required name="country">
|
|
||||||
{{range $entry := .Countries}}
|
|
||||||
<option {{if eq $.Webhook.Country $entry.IsoName}}selected{{end}} value="{{$entry.IsoName}}">{{$entry.Name}}</option>
|
|
||||||
{{end}}
|
|
||||||
</select>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<label for="retry_count">Retry count:</label>
|
|
||||||
<input required id="retry_count" name="retry_count" type="number" min="0" max="10" value="{{.Webhook.RetryCount}}">
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<label class="checkbox"><span>On created</span><input id="on_created" value="true" name="on_created" type="checkbox" {{if .Webhook.OnCreated}}checked{{end}}></label>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<label class="checkbox"><span>On edited</span><input id="on_edited" value="true" name="on_edited" type="checkbox" {{if .Webhook.OnEdited}}checked{{end}}></label>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<label class="checkbox"><span>On deleted</span><input id="on_deleted" value="true" name="on_deleted" type="checkbox" {{if .Webhook.OnDeleted}}checked{{end}}></label>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="actions">
|
|
||||||
<button type="submit">Update</button>
|
|
||||||
<button type="button" onclick="closeDialog('#update-card')">Cancel</button>
|
|
||||||
</section>
|
|
||||||
</form>
|
|
||||||
</dialog>
|
|
|
@ -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>
|
|
@ -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>
|
||||||
|
<a style="all: unset;" href="/documentation">Api dokumentacija</a>
|
||||||
</header>
|
</header>
|
||||||
<nav>
|
<main class="dialog search-dialog">
|
||||||
<section class="container">
|
<form action="/">
|
||||||
<a class="selected" href="#">Search</a>
|
<div class="form-field">
|
||||||
<a href="/documentation">Documentation</a>
|
<label for="year">Za godinu:</label>
|
||||||
<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">
|
<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>
|
|
||||||
</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>
|
</main>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
|
@ -1,59 +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 | Jobs</a></h1>
|
|
||||||
</section>
|
|
||||||
</header>
|
|
||||||
<nav>
|
|
||||||
<section class="container">
|
|
||||||
<a href="/admin">Search</a>
|
|
||||||
<a href="/admin/countries">Countries</a>
|
|
||||||
<a href="/admin/webhooks">Webhooks</a>
|
|
||||||
<a href="#" class="selected">Jobs</a>
|
|
||||||
<button data-type="dialog" data-trigger="#create-card" data-url="/admin/dialogs/add-holiday">Add new holiday</button>
|
|
||||||
</section>
|
|
||||||
</nav>
|
|
||||||
<main>
|
|
||||||
<section style="margin: 1em; flex-grow: 1">
|
|
||||||
|
|
||||||
<table style="width: 100%">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Webhook</th>
|
|
||||||
<th>Created</th>
|
|
||||||
<th>Success</th>
|
|
||||||
<th>Retries remaining</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{{range $entry := .Jobs}}
|
|
||||||
<tr>
|
|
||||||
<td>{{$entry.WebhookId}}</td>
|
|
||||||
<td>{{$entry.Created.Format "2006-01-02 15:04:05"}}</td>
|
|
||||||
<td>
|
|
||||||
<img class="icon" src="{{if $entry.Success}}/assets/images/done-v.svg{{else}}/assets/images/close-x.svg{{end}}">
|
|
||||||
</td>
|
|
||||||
<td>{{$entry.RetryCount}}</td>
|
|
||||||
<td>
|
|
||||||
<button type="button" data-type="dialog" data-trigger="#update-card" data-url="/admin/dialogs/rerun-job?id={{$entry.Id}}" class="clean icon"><img class="icon" src="/assets/images/play.svg"></button>
|
|
||||||
<button type="button" data-type="dialog" data-trigger="#delete-card" data-url="/admin/dialogs/delete-job?id={{$entry.Id}}" class="clean icon"><img class="icon" src="/assets/images/trash-delete.svg"></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{{end}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
|
@ -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>
|
|
@ -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>
|
|
|
@ -1,64 +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 | Webhooks</a></h1>
|
|
||||||
</section>
|
|
||||||
</header>
|
|
||||||
<nav>
|
|
||||||
<section class="container">
|
|
||||||
<a href="/admin">Search</a>
|
|
||||||
<a href="/admin/countries">Countries</a>
|
|
||||||
<a class="selected" href="#">Webhooks</a>
|
|
||||||
<button data-type="dialog" data-trigger="#create-card" data-url="/admin/dialogs/add-holiday">Add new holiday</button>
|
|
||||||
</section>
|
|
||||||
</nav>
|
|
||||||
<main>
|
|
||||||
<section style="margin: 1em; flex-grow: 1">
|
|
||||||
|
|
||||||
<button data-type="dialog" data-trigger="#create-card" data-url="/admin/dialogs/add-webhook" style="margin-bottom: 1em; float: right">Create webhook</button>
|
|
||||||
|
|
||||||
<table style="width: 100%">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Country</th>
|
|
||||||
<th>Url</th>
|
|
||||||
<th>Retry count</th>
|
|
||||||
<th>On create</th>
|
|
||||||
<th>On update</th>
|
|
||||||
<th>On delete</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
|
|
||||||
{{range $entry := .Webhooks}}
|
|
||||||
<tr>
|
|
||||||
<td>{{$entry.Country}}</td>
|
|
||||||
<td>{{$entry.Url}}</td>
|
|
||||||
<td>{{$entry.RetryCount}}</td>
|
|
||||||
<td><img class="icon" src="{{if $entry.OnCreated}}/assets/images/done-v.svg{{else}}/assets/images/close-x.svg{{end}}"></td>
|
|
||||||
<td><img class="icon" src="{{if $entry.OnEdited}}/assets/images/done-v.svg{{else}}/assets/images/close-x.svg{{end}}"></td>
|
|
||||||
<td><img class="icon" src="{{if $entry.OnDeleted}}/assets/images/done-v.svg{{else}}/assets/images/close-x.svg{{end}}"></td>
|
|
||||||
<td>
|
|
||||||
<a style="display: inline-block; font-size: 0.8em" href="/admin/jobs?id={{$entry.Id}}" class="clean icon"><img class="icon" src="/assets/images/invoice.svg"></a>
|
|
||||||
<button type="button" data-type="dialog" data-trigger="#update-card" data-url="/admin/dialogs/edit-webhook?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-webhook?id={{$entry.Id}}" class="clean icon"><img class="icon" src="/assets/images/trash-delete.svg"></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{{end}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
|
@ -1,73 +0,0 @@
|
||||||
package webhook
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type EventType string
|
|
||||||
|
|
||||||
const (
|
|
||||||
TypeCreated EventType = "created"
|
|
||||||
TypeEdited EventType = "edited"
|
|
||||||
TypeDeleted EventType = "deleted"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Event struct {
|
|
||||||
Type EventType `json:"type"`
|
|
||||||
Holiday struct {
|
|
||||||
Id uuid.UUID `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Country string `json:"country"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
Date time.Time `json:"date"`
|
|
||||||
IsStateHoliday bool `json:"is_state_holiday"`
|
|
||||||
IsReligiousHoliday bool `json:"is_religious_holiday"`
|
|
||||||
} `json:"holiday"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e Event) Json() string {
|
|
||||||
content, err := json.Marshal(e)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
return string(content)
|
|
||||||
}
|
|
||||||
|
|
||||||
type Webhook struct {
|
|
||||||
Id uuid.UUID `db:"id"`
|
|
||||||
Created time.Time `db:"created"`
|
|
||||||
Url string `db:"url"`
|
|
||||||
Country string `db:"country"`
|
|
||||||
RetryCount int `db:"retry_count"`
|
|
||||||
OnCreated bool `db:"on_created"`
|
|
||||||
OnEdited bool `db:"on_edited"`
|
|
||||||
OnDeleted bool `db:"on_deleted"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w Webhook) ShouldEmit(e Event) bool {
|
|
||||||
if w.Country == e.Holiday.Country {
|
|
||||||
if e.Type == TypeCreated && w.OnCreated {
|
|
||||||
return true
|
|
||||||
} else if e.Type == TypeEdited && w.OnEdited {
|
|
||||||
return true
|
|
||||||
} else if e.Type == TypeDeleted && w.OnDeleted {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
type Job struct {
|
|
||||||
Id uuid.UUID `db:"id"`
|
|
||||||
WebhookId uuid.UUID `db:"webhook_id"`
|
|
||||||
|
|
||||||
Created time.Time `db:"created"`
|
|
||||||
Success bool `db:"success"`
|
|
||||||
SuccessTime *time.Time `db:"success_time"`
|
|
||||||
|
|
||||||
RetryCount int `db:"retry_count"`
|
|
||||||
|
|
||||||
Content string `db:"content"`
|
|
||||||
}
|
|
|
@ -1,141 +0,0 @@
|
||||||
package webhook
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/jmoiron/sqlx"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type WebhookService struct {
|
|
||||||
DB *sqlx.DB
|
|
||||||
Events <-chan Event
|
|
||||||
Authorization string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *WebhookService) Listen() {
|
|
||||||
for event := range w.Events {
|
|
||||||
webhooks, _ := w.Find()
|
|
||||||
var usedWebhooks []Webhook
|
|
||||||
for _, webhook := range webhooks {
|
|
||||||
usedWebhooks = append(usedWebhooks, webhook)
|
|
||||||
}
|
|
||||||
w.CreateJobs(usedWebhooks, event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *WebhookService) Run() {
|
|
||||||
for {
|
|
||||||
jobs, err := w.findRunningJobs()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("failed fetching running jobs: %v", err)
|
|
||||||
}
|
|
||||||
for _, job := range jobs {
|
|
||||||
job := w.ExecuteJob(job)
|
|
||||||
if err := w.UpdateJob(job); err != nil {
|
|
||||||
log.Printf("failed updating job %v: %v", job.Id, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
time.Sleep(2 * time.Hour)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *WebhookService) Find() ([]Webhook, error) {
|
|
||||||
var webhooks []Webhook
|
|
||||||
return webhooks, w.DB.Select(&webhooks, `SELECT * FROM "webhook"`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *WebhookService) FindAllJobs() ([]Job, error) {
|
|
||||||
var jobs []Job
|
|
||||||
return jobs, w.DB.Select(&jobs, `SELECT * FROM "job"`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *WebhookService) FindJobs(webhookId uuid.UUID) ([]Job, error) {
|
|
||||||
var jobs []Job
|
|
||||||
return jobs, w.DB.Select(&jobs, `SELECT * FROM "job" WHERE id = $1`, webhookId)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *WebhookService) findRunningJobs() ([]Job, error) {
|
|
||||||
var jobs []Job
|
|
||||||
return jobs, w.DB.Select(&jobs, `SELECT * FROM "job" WHERE retry_count > 0 ORDER BY created asc`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *WebhookService) Create(webhook Webhook) (Webhook, error) {
|
|
||||||
_, err := w.DB.Exec("INSERT INTO webhook (id, created, url, country, retry_count, on_created, on_edited, on_deleted) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
|
|
||||||
&webhook.Id, &webhook.Created, &webhook.Url, &webhook.Country, &webhook.RetryCount, &webhook.OnCreated, &webhook.OnEdited, &webhook.OnDeleted,
|
|
||||||
)
|
|
||||||
return webhook, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *WebhookService) Update(webhook Webhook) (Webhook, error) {
|
|
||||||
_, err := w.DB.Exec("UPDATE webhook SET url = $2, country = $3, retry_count = $4, on_created = $5, on_edited = $6, on_deleted = $7 WHERE id = $1",
|
|
||||||
&webhook.Id, &webhook.Url, &webhook.Country, &webhook.RetryCount, &webhook.OnCreated, &webhook.OnEdited, &webhook.OnDeleted,
|
|
||||||
)
|
|
||||||
return webhook, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *WebhookService) CreateJobs(webhooks []Webhook, event Event) {
|
|
||||||
json := event.Json()
|
|
||||||
|
|
||||||
tx, _ := w.DB.Begin()
|
|
||||||
for _, webhook := range webhooks {
|
|
||||||
job := Job{
|
|
||||||
Id: uuid.Must(uuid.NewRandom()),
|
|
||||||
WebhookId: webhook.Id,
|
|
||||||
Created: time.Now(),
|
|
||||||
Success: false,
|
|
||||||
SuccessTime: nil,
|
|
||||||
RetryCount: webhook.RetryCount,
|
|
||||||
Content: json,
|
|
||||||
}
|
|
||||||
_, err := tx.Exec("INSERT INTO job (id, webhook_id, created, success, success_time, retry_count, content) VALUES ($1, $2, $3, $4, $5, $6, $7)",
|
|
||||||
&job.Id, &job.WebhookId, &job.Created, &job.Success, &job.SuccessTime, &job.RetryCount, &job.Content,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
tx.Rollback()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tx.Commit()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *WebhookService) ExecuteJob(job Job) Job {
|
|
||||||
webhook, err := w.FindById(job.WebhookId)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
log.Printf("executing job [%d:%d] %v: POST %s", webhook.RetryCount-job.RetryCount, webhook.RetryCount, job.Id, webhook.Url)
|
|
||||||
request, err := http.NewRequest("POST", webhook.Url, strings.NewReader(job.Content))
|
|
||||||
request.Header.Add("authorization", "Api "+w.Authorization)
|
|
||||||
|
|
||||||
response, err := http.DefaultClient.Do(request)
|
|
||||||
if err != nil || response.StatusCode != http.StatusOK {
|
|
||||||
job.RetryCount--
|
|
||||||
} else {
|
|
||||||
job.Success = true
|
|
||||||
currentTime := time.Now()
|
|
||||||
job.SuccessTime = ¤tTime
|
|
||||||
}
|
|
||||||
return job
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *WebhookService) FindById(webhookId uuid.UUID) (Webhook, error) {
|
|
||||||
var webhook Webhook
|
|
||||||
return webhook, w.DB.Get(&webhook, `SELECT * FROM "webhook" WHERE id = $1`, webhookId)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *WebhookService) UpdateJob(job Job) error {
|
|
||||||
_, err := w.DB.Exec(`UPDATE job SET success = $2, success_time = $3, retry_count = $4 WHERE id = $1`,
|
|
||||||
&job.Id, &job.Success, &job.SuccessTime, &job.RetryCount,
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *WebhookService) Delete(webhookId uuid.UUID) error {
|
|
||||||
_, err := w.DB.Exec(`DELETE FROM "webhook" WHERE id = $1`,
|
|
||||||
&webhookId,
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|