Compare commits
1 Commits
v0.3.1
...
55e132d80b
| Author | SHA1 | Date | |
|---|---|---|---|
|
55e132d80b
|
@@ -63,6 +63,9 @@ You can then run the binary.
|
|||||||
| `PASSPORT_SEARCH_PROVIDER` | The search provider to use for the search bar, without any query parameters | true |
|
| `PASSPORT_SEARCH_PROVIDER` | The search provider to use for the search bar, without any query parameters | true |
|
||||||
| `PASSPORT_SEARCH_PROVIDER_QUERY_PARAM` | The query parameter to use for the search provider, e.g. `q` for most providers | false | q |
|
| `PASSPORT_SEARCH_PROVIDER_QUERY_PARAM` | The query parameter to use for the search provider, e.g. `q` for most providers | false | q |
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Currently passport only supports search using a GET request.
|
||||||
|
|
||||||
#### Weather configuration
|
#### Weather configuration
|
||||||
|
|
||||||
The weather integration is optional, and will be enabled automatically if you provide an API key. The following only applies if you are using the OpenWeatherMap integration.
|
The weather integration is optional, and will be enabled automatically if you provide an API key. The following only applies if you are using the OpenWeatherMap integration.
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -5,6 +5,8 @@ go 1.25.0
|
|||||||
require (
|
require (
|
||||||
github.com/HugoSmits86/nativewebp v1.2.0
|
github.com/HugoSmits86/nativewebp v1.2.0
|
||||||
github.com/caarlos0/env/v11 v11.3.1
|
github.com/caarlos0/env/v11 v11.3.1
|
||||||
|
github.com/disintegration/imaging v1.6.2
|
||||||
|
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd
|
||||||
golang.org/x/image v0.24.0
|
golang.org/x/image v0.24.0
|
||||||
modernc.org/sqlite v1.39.0
|
modernc.org/sqlite v1.39.0
|
||||||
)
|
)
|
||||||
|
|||||||
6
go.sum
6
go.sum
@@ -7,6 +7,8 @@ github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vaui
|
|||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||||
|
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||||
@@ -51,6 +53,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
|
|||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
|
||||||
|
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
|
||||||
github.com/shamaton/msgpack/v2 v2.3.0 h1:eawIa7lQmwRv0V6rdmL/5Ev9KdJHk07eQH3ceJi3BUw=
|
github.com/shamaton/msgpack/v2 v2.3.0 h1:eawIa7lQmwRv0V6rdmL/5Ev9KdJHk07eQH3ceJi3BUw=
|
||||||
github.com/shamaton/msgpack/v2 v2.3.0/go.mod h1:6khjYnkx73f7VQU7wjcFS9DFjs+59naVWJv1TB7qdOI=
|
github.com/shamaton/msgpack/v2 v2.3.0/go.mod h1:6khjYnkx73f7VQU7wjcFS9DFjs+59naVWJv1TB7qdOI=
|
||||||
github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
|
github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
|
||||||
@@ -74,6 +78,7 @@ golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
|||||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||||
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
|
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
|
||||||
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
||||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||||
@@ -86,6 +91,7 @@ golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||||
|
|||||||
370
src/main.go
370
src/main.go
@@ -1,4 +1,4 @@
|
|||||||
//go:generate tailwindcss -i styles/main.css -o assets/tailwind.css --minify
|
//go:generate tailwindcss -i styles/main.scss -o assets/tailwind.css --minify
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
@@ -27,6 +27,7 @@ import (
|
|||||||
|
|
||||||
"github.com/HugoSmits86/nativewebp"
|
"github.com/HugoSmits86/nativewebp"
|
||||||
"github.com/caarlos0/env/v11"
|
"github.com/caarlos0/env/v11"
|
||||||
|
"github.com/disintegration/imaging"
|
||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
"github.com/gofiber/fiber/v3/middleware/helmet"
|
"github.com/gofiber/fiber/v3/middleware/helmet"
|
||||||
"github.com/gofiber/fiber/v3/middleware/static"
|
"github.com/gofiber/fiber/v3/middleware/static"
|
||||||
@@ -35,6 +36,8 @@ import (
|
|||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
"github.com/juls0730/passport/src/middleware"
|
"github.com/juls0730/passport/src/middleware"
|
||||||
"github.com/juls0730/passport/src/services"
|
"github.com/juls0730/passport/src/services"
|
||||||
|
"github.com/rwcarlsen/goexif/exif"
|
||||||
|
"github.com/rwcarlsen/goexif/tiff"
|
||||||
"golang.org/x/image/draw"
|
"golang.org/x/image/draw"
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
@@ -44,23 +47,36 @@ var embeddedAssets embed.FS
|
|||||||
|
|
||||||
var devContent = `<script>
|
var devContent = `<script>
|
||||||
let host = window.location.hostname;
|
let host = window.location.hostname;
|
||||||
const socket = new WebSocket('ws://' + host + ':2067/ws');
|
let protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const socket = new WebSocket(protocol + '//' + host + ':2067/ws');
|
||||||
|
|
||||||
socket.addEventListener('message', (event) => {
|
socket.addEventListener('message', (event) => {
|
||||||
if (event.data === 'refresh') {
|
if (event.data === 'refresh') {
|
||||||
|
console.log('Got refresh signal');
|
||||||
|
|
||||||
|
let attempts = 0;
|
||||||
|
let delay = 100;
|
||||||
|
|
||||||
async function testPage() {
|
async function testPage() {
|
||||||
try {
|
try {
|
||||||
let res = await fetch(window.location.href)
|
let res = await fetch(window.location.href)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
if (attempts > 5) {
|
||||||
setTimeout(testPage, 300);
|
return;
|
||||||
|
}
|
||||||
|
setTimeout(testPage, delay);
|
||||||
|
|
||||||
|
// exponential backoff
|
||||||
|
attempts++;
|
||||||
|
delay = 100 * Math.pow(2, attempts);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
testPage();
|
setTimeout(testPage, 150);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>`
|
</script>`
|
||||||
|
|
||||||
@@ -282,8 +298,47 @@ func UploadFile(file *multipart.FileHeader, fileName, contentType string, c fibe
|
|||||||
return "", errors.New("unsupported file type")
|
return "", errors.New("unsupported file type")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if contentType != "image/svg+xml" {
|
||||||
return "", err
|
off, err := srcFile.Seek(0, io.SeekStart)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to seek to start of file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if off != 0 {
|
||||||
|
return "", fmt.Errorf("failed to seek to start of file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
x, err := exif.Decode(srcFile)
|
||||||
|
// if there *is* exif, parse it
|
||||||
|
if err == nil {
|
||||||
|
tag, err := x.Get(exif.Orientation)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get orientation: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tag.Count == 1 && tag.Format() == tiff.IntVal {
|
||||||
|
orientation, err := tag.Int(0)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get orientation: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Debug("Orientation tag found", "orientation", orientation)
|
||||||
|
|
||||||
|
switch orientation {
|
||||||
|
case 3:
|
||||||
|
img = imaging.Rotate180(img)
|
||||||
|
case 6:
|
||||||
|
img = imaging.Rotate270(img)
|
||||||
|
case 8:
|
||||||
|
img = imaging.Rotate90(img)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
img, err = CropToCenter(img, 96)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
assetsDir := "public/uploads"
|
assetsDir := "public/uploads"
|
||||||
@@ -291,7 +346,21 @@ func UploadFile(file *multipart.FileHeader, fileName, contentType string, c fibe
|
|||||||
iconPath := filepath.Join(assetsDir, fileName)
|
iconPath := filepath.Join(assetsDir, fileName)
|
||||||
|
|
||||||
if contentType == "image/svg+xml" {
|
if contentType == "image/svg+xml" {
|
||||||
if err = c.SaveFile(file, iconPath); err != nil {
|
// replace currentColor with a text color
|
||||||
|
outFile, err := os.Create(iconPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer outFile.Close()
|
||||||
|
|
||||||
|
svgText, err := io.ReadAll(srcFile)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
svgText = bytes.ReplaceAll(svgText, []byte("currentColor"), []byte(`oklch(87% 0.015 286)`))
|
||||||
|
_, err = outFile.Write(svgText)
|
||||||
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -301,16 +370,9 @@ func UploadFile(file *multipart.FileHeader, fileName, contentType string, c fibe
|
|||||||
}
|
}
|
||||||
defer outFile.Close()
|
defer outFile.Close()
|
||||||
|
|
||||||
// crop slightly larger than 64px to vastly increase the quality of the image, but not increase the file size
|
|
||||||
// *too* much and so that we dont have a ton of extra file data that will never be seen by the user
|
|
||||||
resizedImg, err := CropToCenter(img, 96)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
options := &nativewebp.Options{}
|
options := &nativewebp.Options{}
|
||||||
if err := nativewebp.Encode(&buf, resizedImg, options); err != nil {
|
if err := nativewebp.Encode(&buf, img, options); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -790,6 +852,14 @@ func main() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
|
|
||||||
|
if len(req.Name) > 50 {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"message": "Name is too long. Maximum length is 50 characters",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
file, err := c.FormFile("icon")
|
file, err := c.FormFile("icon")
|
||||||
if err != nil || file == nil {
|
if err != nil || file == nil {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||||
@@ -810,7 +880,7 @@ func main() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
filename := fmt.Sprintf("%d_%s.svg", time.Now().Unix(), strings.ReplaceAll(req.Name, " ", "_"))
|
filename := fmt.Sprintf("%d_%s.svg", time.Now().Unix(), strings.ReplaceAll(req.Name[:min(10, len(req.Name))], " ", "_"))
|
||||||
|
|
||||||
iconPath, err := UploadFile(file, filename, contentType, c)
|
iconPath, err := UploadFile(file, filename, contentType, c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -819,8 +889,6 @@ func main() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
UploadFile(file, iconPath, contentType, c)
|
|
||||||
|
|
||||||
category, err := app.CategoryManager.CreateCategory(Category{
|
category, err := app.CategoryManager.CreateCategory(Category{
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Icon: iconPath,
|
Icon: iconPath,
|
||||||
@@ -857,6 +925,23 @@ func main() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
|
if req.Description != "" {
|
||||||
|
req.Description = strings.TrimSpace(req.Description)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(req.Name) > 50 {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"message": "Name is too long. Maximum length is 50 characters",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(req.Description) > 150 {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"message": "Description is too long. Maximum length is 150 characters",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
categoryID, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
categoryID, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||||
@@ -890,7 +975,7 @@ func main() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
filename := fmt.Sprintf("%d_%s.webp", time.Now().Unix(), strings.ReplaceAll(req.Name, " ", "_"))
|
filename := fmt.Sprintf("%d_%s.webp", time.Now().Unix(), strings.ReplaceAll(req.Name[:min(10, len(req.Name))], " ", "_"))
|
||||||
|
|
||||||
iconPath, err := UploadFile(file, filename, contentType, c)
|
iconPath, err := UploadFile(file, filename, contentType, c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -900,8 +985,6 @@ func main() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
UploadFile(file, iconPath, contentType, c)
|
|
||||||
|
|
||||||
link, err := app.CategoryManager.CreateLink(app.CategoryManager.db, Link{
|
link, err := app.CategoryManager.CreateLink(app.CategoryManager.db, Link{
|
||||||
CategoryID: categoryID,
|
CategoryID: categoryID,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
@@ -922,6 +1005,245 @@ func main() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
api.Patch("/category/:id", func(c fiber.Ctx) error {
|
||||||
|
var req struct {
|
||||||
|
Name string `form:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Params("id") == "" {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"message": "ID is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"message": fmt.Sprintf("Failed to parse category ID: %v", err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.Bind().Form(&req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"message": "Failed to parse request",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Name != "" {
|
||||||
|
if len(req.Name) > 50 {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"message": "Name is too long. Maximum length is 50 characters",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
category := app.CategoryManager.GetCategory(id)
|
||||||
|
if category == nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"message": "Category not found",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := app.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||||
|
"message": "Failed to start transaction",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
file, err := c.FormFile("icon")
|
||||||
|
if err == nil {
|
||||||
|
if file.Size > 5*1024*1024 {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"message": "File size too large. Maximum size is 5MB",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType := file.Header.Get("Content-Type")
|
||||||
|
if contentType != "image/svg+xml" {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"message": "Only svg files are allowed",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
oldIconPath := category.Icon
|
||||||
|
|
||||||
|
filename := fmt.Sprintf("%d_%s.svg", time.Now().Unix(), strings.ReplaceAll(req.Name[:min(10, len(req.Name))], " ", "_"))
|
||||||
|
|
||||||
|
iconPath, err := UploadFile(file, filename, contentType, c)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to upload file", "error", err)
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||||
|
"message": "Failed to upload file, please try again!",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec("UPDATE categories SET icon = ? WHERE id = ?", iconPath, id)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||||
|
"message": "Failed to update category",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.Remove(filepath.Join("public/", oldIconPath))
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to delete icon", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Name != "" {
|
||||||
|
_, err = tx.Exec("UPDATE categories SET name = ? WHERE id = ?", req.Name, category.ID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||||
|
"message": "Failed to update category",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Commit()
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||||
|
"message": "Failed to commit transaction",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
||||||
|
"message": "Category updated successfully",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
api.Patch("/category/:categoryID/link/:linkID", func(c fiber.Ctx) error {
|
||||||
|
var req struct {
|
||||||
|
Name string `form:"name"`
|
||||||
|
Description string `form:"description"`
|
||||||
|
Icon string `form:"icon"`
|
||||||
|
}
|
||||||
|
if err := c.Bind().Form(&req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"message": "Failed to parse request",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(req.Name) > 50 {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"message": "Name is too long. Maximum length is 50 characters",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(req.Description) > 150 {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"message": "Description is too long. Maximum length is 150 characters",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
linkID, err := strconv.ParseInt(c.Params("linkID"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"message": fmt.Sprintf("Failed to parse link ID: %v", err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryID, err := strconv.ParseInt(c.Params("categoryID"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"message": fmt.Sprintf("Failed to parse category ID: %v", err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if app.CategoryManager.GetCategory(categoryID) == nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"message": "Category not found",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
link := app.CategoryManager.GetLink(linkID)
|
||||||
|
if link == nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"message": "Link not found",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := app.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||||
|
"message": "Failed to start transaction",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
file, err := c.FormFile("icon")
|
||||||
|
if err == nil {
|
||||||
|
if file.Size > 5*1024*1024 {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"message": "File size too large. Maximum size is 5MB",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType := file.Header.Get("Content-Type")
|
||||||
|
if !strings.HasPrefix(contentType, "image/") {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"message": "Only image files are allowed",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
oldIconPath := link.Icon
|
||||||
|
|
||||||
|
filename := fmt.Sprintf("%d_%s.webp", time.Now().Unix(), strings.ReplaceAll(req.Name[:min(10, len(req.Name))], " ", "_"))
|
||||||
|
|
||||||
|
iconPath, err := UploadFile(file, filename, contentType, c)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to upload file", "error", err)
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||||
|
"message": "Failed to upload file, please try again!",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec("UPDATE links SET icon = ? WHERE id = ?", iconPath, linkID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||||
|
"message": "Failed to update link",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.Remove(filepath.Join("public/", oldIconPath))
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to delete icon", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Name != "" {
|
||||||
|
_, err = tx.Exec("UPDATE links SET name = ? WHERE id = ?", req.Name, linkID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||||
|
"message": "Failed to update link",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Description != "" {
|
||||||
|
_, err = tx.Exec("UPDATE links SET description = ? WHERE id = ?", req.Description, linkID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||||
|
"message": "Failed to update link",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Commit()
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||||
|
"message": "Failed to commit transaction",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("Link updated successfully", "id", linkID, "name", req.Name)
|
||||||
|
|
||||||
|
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
||||||
|
"message": "Link updated successfully",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
api.Delete("/category/:categoryID/link/:linkID", func(c fiber.Ctx) error {
|
api.Delete("/category/:categoryID/link/:linkID", func(c fiber.Ctx) error {
|
||||||
linkID, err := strconv.ParseInt(c.Params("linkID"), 10, 64)
|
linkID, err := strconv.ParseInt(c.Params("linkID"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -4,11 +4,11 @@
|
|||||||
@theme {
|
@theme {
|
||||||
--color-accent: oklch(57.93% 0.258 294.12);
|
--color-accent: oklch(57.93% 0.258 294.12);
|
||||||
--color-success: oklch(70.19% 0.158 160.44);
|
--color-success: oklch(70.19% 0.158 160.44);
|
||||||
--color-error: oklch(63.43% 0.251 28.48);
|
--color-error: oklch(53% 0.251 28.48);
|
||||||
|
|
||||||
--color-base: oklch(11% .007 285);
|
--color-base: oklch(11% .007 285);
|
||||||
--color-surface: oklch(19% 0.007 314.66);
|
--color-surface: oklch(19% 0.007 285.66);
|
||||||
--color-overlay: oklch(26% 0.008 314.66);
|
--color-overlay: oklch(26% 0.008 285.66);
|
||||||
|
|
||||||
--color-muted: oklch(63% 0.015 286);
|
--color-muted: oklch(63% 0.015 286);
|
||||||
--color-subtle: oklch(72% 0.015 286);
|
--color-subtle: oklch(72% 0.015 286);
|
||||||
@@ -61,7 +61,7 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
input:not(.search) {
|
input:not(.search) {
|
||||||
@apply px-4 py-2 rounded-md w-full bg-surface border border-highlight-sm/70 placeholder:text-highlight text-text focus-visible:outline-none transition-colors duration-300 ease-out overflow-hidden;
|
@apply px-4 py-2 rounded-md w-full bg-surface border border-highlight/70 placeholder:text-highlight text-text focus-visible:outline-none transition-colors duration-300 ease-out overflow-hidden;
|
||||||
|
|
||||||
&[type="file"] {
|
&[type="file"] {
|
||||||
@apply p-0 cursor-pointer;
|
@apply p-0 cursor-pointer;
|
||||||
@@ -85,14 +85,16 @@ input:not(.search) {
|
|||||||
transition-timing-function: cubic-bezier(0.45, 0, 0.55, 1);
|
transition-timing-function: cubic-bezier(0.45, 0, 0.55, 1);
|
||||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||||
|
|
||||||
&:hover {
|
&:not(.admin) {
|
||||||
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
&:hover {
|
||||||
transform: translateY(-4px);
|
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||||
}
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
box-shadow: 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
box-shadow: 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||||
transform: translateY(2px);
|
transform: translateY(2px);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,18 +112,24 @@ input:not(.search) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-card img {
|
/* Div that holds the image */
|
||||||
|
.link-card div:has(img):first-child {
|
||||||
|
flex-shrink: 0;
|
||||||
margin-right: 0.5rem;
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-card div:first-child img {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
border-radius: 0.375rem;
|
border-radius: 0.375rem;
|
||||||
aspect-ratio: 1/1;
|
aspect-ratio: 1/1;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-card div {
|
/* Div that holds the text */
|
||||||
|
.link-card div:nth-child(2) {
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-card div p {
|
.link-card div:nth-child(2) p {
|
||||||
color: var(--color-subtle);
|
color: var(--color-subtle);
|
||||||
}
|
}
|
||||||
17
src/templates/partials/modals/category-form.hbs
Normal file
17
src/templates/partials/modals/category-form.hbs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<div id="category-contents" class="hidden">
|
||||||
|
<h3>Create A category</h3>
|
||||||
|
<form id="category-form" action="/api/categories" method="post"
|
||||||
|
class="flex flex-col gap-y-3 my-2 [&>div]:flex [&>div]:flex-col [&>div]:gap-1">
|
||||||
|
<div>
|
||||||
|
<label for="categoryName">Name</label>
|
||||||
|
<input required type="text" name="name" id="categoryName" maxlength="50" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="linkIcon">Icon</label>
|
||||||
|
<input type="file" name="icon" id="linkIcon" accept=".svg" required />
|
||||||
|
</div>
|
||||||
|
<button class="px-4 py-2 rounded-md w-full bg-accent text-white border-0" type="submit">Create
|
||||||
|
category</button>
|
||||||
|
</form>
|
||||||
|
<span id="category-message"></span>
|
||||||
|
</div>
|
||||||
13
src/templates/partials/modals/delete-category.hbs
Normal file
13
src/templates/partials/modals/delete-category.hbs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<div id="category-delete-contents" class="hidden text-center">
|
||||||
|
<h3>Are you sure you want to delete this category?</h3>
|
||||||
|
<p class="mb-3">You are about to delete the category <strong id="category-name"></strong>. This action cannot be
|
||||||
|
undone.
|
||||||
|
All links associated with this category will also be deleted. Are you sure you want to continue?</p>
|
||||||
|
<div class="flex justify-end flex-col gap-y-2">
|
||||||
|
<button class="px-4 py-2 rounded-md w-full bg-error text-white border-0"
|
||||||
|
onclick="confirmDeleteCategory()">Delete
|
||||||
|
category</button>
|
||||||
|
<button class="px-4 py-2 rounded-md w-full bg-overlay border border-highlight text-white"
|
||||||
|
onclick="closeModal()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
12
src/templates/partials/modals/delete-link.hbs
Normal file
12
src/templates/partials/modals/delete-link.hbs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<div id="link-delete-contents" class="hidden text-center">
|
||||||
|
<h3>Are you sure you want to delete this link?</h3>
|
||||||
|
<p class="mb-3">You are about to delete the link <strong id="link-name"></strong>. This action cannot be undone. Are
|
||||||
|
you sure you
|
||||||
|
want to continue?</p>
|
||||||
|
<div class="flex justify-end flex-col gap-y-2">
|
||||||
|
<button class="px-4 py-2 rounded-md w-full bg-error text-white border-0" onclick="confirmDeleteLink()">Delete
|
||||||
|
link</button>
|
||||||
|
<button class="px-4 py-2 rounded-md w-full bg-overlay border border-highlight text-white"
|
||||||
|
onclick="closeModal()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
25
src/templates/partials/modals/link-form.hbs
Normal file
25
src/templates/partials/modals/link-form.hbs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<div id="link-contents" class="hidden">
|
||||||
|
<h3>Add A link</h3>
|
||||||
|
<form id="link-form" action="/api/links" method="post"
|
||||||
|
class="flex flex-col gap-y-3 my-2 [&>div]:flex [&>div]:flex-col [&>div]:gap-1">
|
||||||
|
<div>
|
||||||
|
<label for="linkName">Name</label>
|
||||||
|
<input required type="text" name="name" id="linkName" maxlength="50" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="linkDesc">Description (optional)</label>
|
||||||
|
<input type="text" name="description" id="linkDesc" maxlength="150" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="linkURL">URL</label>
|
||||||
|
<input required type="url" name="url" id="linkURL" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="linkIcon">Icon</label>
|
||||||
|
<input required type="file" name="icon" id="linkIcon" accept="image/*" />
|
||||||
|
</div>
|
||||||
|
<button class="px-4 py-2 rounded-md w-full bg-accent text-white border-0" type="submit">Add
|
||||||
|
link</button>
|
||||||
|
</form>
|
||||||
|
<span id="link-message"></span>
|
||||||
|
</div>
|
||||||
@@ -18,32 +18,129 @@
|
|||||||
<div class="w-full sm:w-4/5 p-2.5">
|
<div class="w-full sm:w-4/5 p-2.5">
|
||||||
{{#each Categories}}
|
{{#each Categories}}
|
||||||
<div class="flex items-center" key="category-{{this.ID}}">
|
<div class="flex items-center" key="category-{{this.ID}}">
|
||||||
<img class="object-contain mr-2 select-none" width="32" height="32" draggable="false"
|
<div class="shrink-0 relative mr-2 h-full flex items-center justify-center">
|
||||||
alt="{{this.Name}}" src="{{this.Icon}}" />
|
<img class="object-contain select-none" width="32" height="32" draggable="false" alt="{{this.Name}}"
|
||||||
<h2 class="capitalize break-all">{{this.Name}}</h2>
|
src="{{this.Icon}}" />
|
||||||
<button onclick="deleteCategory({{this.ID}})"
|
<button onclick="selectIcon()"
|
||||||
class="w-fit h-fit flex p-0.5 bg-base border border-highlight rounded-md hover:filter hover:brightness-125 cursor-pointer"><svg
|
class="absolute inset-0 bg-highlight/80 hidden rounded-md text-base items-center justify-center"
|
||||||
xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
draggable="false">
|
||||||
<path fill="none" stroke="#ff1919" stroke-linecap="round" stroke-linejoin="round"
|
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28"
|
||||||
stroke-width="2"
|
viewBox="0 0 24 24"><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
|
||||||
d="M4 7h16m-10 4v6m4-6v6M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3" />
|
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||||
</svg></button>
|
stroke-width="2" d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2M7 9l5-5l5 5m-5-5v12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<h2 class="capitalize break-all border border-transparent">{{this.Name}}</h2>
|
||||||
|
<div class="ml-2" data-edit-actions>
|
||||||
|
<div class="flex flex-row gap-2">
|
||||||
|
<button aria-label="Edit category" onclick="editCategory({{this.ID}})"
|
||||||
|
class="w-fit h-fit flex p-1 bg-highlight-sm shadow-sm border border-highlight/70 rounded-full hover:filter hover:brightness-125 active:brightness-95 cursor-pointer transition-[filter] ease-[cubic-bezier(0.45,0,0.55,1)] duration-150">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
|
||||||
|
viewBox="0 0 24 24"><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
|
||||||
|
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
stroke-width="2">
|
||||||
|
<path d="M7 7H6a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2v-1" />
|
||||||
|
<path d="M20.385 6.585a2.1 2.1 0 0 0-2.97-2.97L9 12v3h3zM16 5l3 3" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button aria-label="Delete category" onclick="deleteCategory({{this.ID}})"
|
||||||
|
class="text-error w-fit h-fit flex p-1 bg-highlight-sm shadow-sm border border-highlight/70 rounded-full hover:filter hover:brightness-125 active:brightness-95 cursor-pointer transition-[filter] ease-[cubic-bezier(0.45,0,0.55,1)] duration-150">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
|
||||||
|
viewBox="0 0 24 24"><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
|
||||||
|
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 7h16m-10 4v6m4-6v6M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="hidden flex-row gap-2">
|
||||||
|
<button aria-label="Confirm category edit" onclick="confirmCategoryEdit({{this.ID}})"
|
||||||
|
class="w-fit h-fit flex p-1 bg-highlight-sm shadow-sm border border-highlight/70 rounded-full hover:filter hover:brightness-125 active:brightness-95 cursor-pointer transition-[filter] ease-[cubic-bezier(0.45,0,0.55,1)] duration-150 text-success">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
|
||||||
|
viewBox="0 0 24 24"><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
|
||||||
|
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
stroke-width="2" d="m5 12l5 5L20 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button aria-label="Cancel category edit" onclick="cancelCategoryEdit({{this.ID}})"
|
||||||
|
class="w-fit h-fit flex p-1 bg-highlight-sm shadow-sm border border-highlight/70 rounded-full hover:filter hover:brightness-125 active:brightness-95 cursor-pointer transition-[filter] ease-[cubic-bezier(0.45,0,0.55,1)] duration-150 text-error">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
|
||||||
|
viewBox="0 0 24 24"><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
|
||||||
|
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0-18 0m15.364-6.364L5.636 18.364" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-2.5 grid grid-cols-[repeat(auto-fill,_minmax(min(330px,_100%),_1fr))] gap-2">
|
<div class="p-2.5 grid grid-cols-[repeat(auto-fill,_minmax(min(330px,_100%),_1fr))] gap-2">
|
||||||
{{#each this.Links}}
|
{{#each this.Links}}
|
||||||
<div key="link-{{this.ID}}" class="link-card relative">
|
<div key="link-{{this.ID}}" class="link-card relative admin">
|
||||||
<img width="64" height="64" draggable="false" src="{{this.Icon}}" alt="{{this.Name}}" />
|
<div class="relative">
|
||||||
<div>
|
<img width="64" height="64" draggable="false" src="{{this.Icon}}" alt="{{this.Name}}" />
|
||||||
<h3>{{this.Name}}</h3>
|
<button onclick="selectIcon()"
|
||||||
<p>{{this.Description}}</p>
|
class="absolute inset-0 bg-highlight/80 hidden rounded-md text-base items-center justify-center"
|
||||||
|
draggable="false">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28"
|
||||||
|
viewBox="0 0 24 24"><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
|
||||||
|
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2M7 9l5-5l5 5m-5-5v12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow">
|
||||||
|
<h3 class="border border-transparent">{{this.Name}}</h3>
|
||||||
|
<p class="min-h-5">{{this.Description}}</p>
|
||||||
|
</div>
|
||||||
|
<div class="absolute right-1 top-1" data-edit-actions>
|
||||||
|
<div class="flex flex-row gap-2">
|
||||||
|
<button aria-label="Edit link" onclick="editLink({{this.ID}}, {{this.CategoryID}})"
|
||||||
|
class="w-fit h-fit flex p-1 bg-highlight-sm shadow-sm border border-highlight/70 rounded-full hover:filter hover:brightness-125 active:brightness-95 cursor-pointer transition-[filter] ease-[cubic-bezier(0.45,0,0.55,1)] duration-150">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
|
||||||
|
viewBox="0 0 24 24"><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
|
||||||
|
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
stroke-width="2">
|
||||||
|
<path d="M7 7H6a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2v-1" />
|
||||||
|
<path d="M20.385 6.585a2.1 2.1 0 0 0-2.97-2.97L9 12v3h3zM16 5l3 3" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button aria-label="Delete link" onclick="deleteLink({{this.ID}}, {{this.CategoryID}})"
|
||||||
|
class="text-error w-fit h-fit flex p-1 bg-highlight-sm shadow-sm border border-highlight/70 rounded-full hover:filter hover:brightness-125 active:brightness-95 cursor-pointer transition-[filter] ease-[cubic-bezier(0.45,0,0.55,1)] duration-150">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
|
||||||
|
viewBox="0 0 24 24"><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
|
||||||
|
<path fill="none" stroke="currentColor" stroke-linecap="round"
|
||||||
|
stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M4 7h16m-10 4v6m4-6v6M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="hidden flex-row gap-2">
|
||||||
|
<button aria-label="Confirm link edit"
|
||||||
|
onclick="confirmLinkEdit({{this.ID}}, {{this.CategoryID}})"
|
||||||
|
class="w-fit h-fit flex p-1 bg-highlight-sm shadow-sm border border-highlight/70 rounded-full hover:filter hover:brightness-125 active:brightness-95 cursor-pointer transition-[filter] ease-[cubic-bezier(0.45,0,0.55,1)] duration-150 text-success">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
|
||||||
|
viewBox="0 0 24 24"><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
|
||||||
|
<path fill="none" stroke="currentColor" stroke-linecap="round"
|
||||||
|
stroke-linejoin="round" stroke-width="2" d="m5 12l5 5L20 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button aria-label="Cancel link edit"
|
||||||
|
onclick="cancelLinkEdit({{this.ID}}, {{this.CategoryID}})"
|
||||||
|
class="w-fit h-fit flex p-1 bg-highlight-sm shadow-sm border border-highlight/70 rounded-full hover:filter hover:brightness-125 active:brightness-95 cursor-pointer transition-[filter] ease-[cubic-bezier(0.45,0,0.55,1)] duration-150 text-error">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
|
||||||
|
viewBox="0 0 24 24"><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
|
||||||
|
<path fill="none" stroke="currentColor" stroke-linecap="round"
|
||||||
|
stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0-18 0m15.364-6.364L5.636 18.364" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button onclick="deleteLink({{this.ID}}, {{this.CategoryID}})"
|
|
||||||
class="w-fit h-fit flex p-0.5 bg-base border border-highlight/70 rounded-md hover:filter hover:brightness-125 cursor-pointer absolute right-1 top-1"><svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
|
||||||
<path fill="none" stroke="#ff1919" stroke-linecap="round" stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M4 7h16m-10 4v6m4-6v6M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3" />
|
|
||||||
</svg></button>
|
|
||||||
</div>
|
</div>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
<div onclick="openModal('link', {{this.ID}})"
|
<div onclick="openModal('link', {{this.ID}})"
|
||||||
@@ -71,52 +168,14 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="modal-container"
|
<input type="file" id="icon-upload" accept="image/*" style="display: none;" />
|
||||||
|
<div id="modal-container" role="dialog" aria-modal="true"
|
||||||
class="flex modal-bg fixed top-0 left-0 bottom-0 right-0 bg-black/45 justify-center items-center">
|
class="flex modal-bg fixed top-0 left-0 bottom-0 right-0 bg-black/45 justify-center items-center">
|
||||||
<div class="bg-overlay rounded-xl overflow-hidden w-full p-4 modal max-w-sm">
|
<div class="bg-overlay rounded-xl overflow-hidden w-full p-4 modal max-w-sm">
|
||||||
<div id="category-contents" class="hidden">
|
{{> 'partials/modals/category-form' }}
|
||||||
<h3>Create A category</h3>
|
{{> 'partials/modals/link-form' }}
|
||||||
<form id="category-form" action="/api/categories" method="post"
|
{{> 'partials/modals/delete-link' }}
|
||||||
class="flex flex-col gap-y-3 my-2 [&>div]:flex [&>div]:flex-col [&>div]:gap-1">
|
{{> 'partials/modals/delete-category' }}
|
||||||
<div>
|
|
||||||
<label for="categoryName">Name</label>
|
|
||||||
<input required type="text" name="name" id="categoryName" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="linkIcon">Icon</label>
|
|
||||||
<input type="file" name="icon" id="linkIcon" accept=".svg" required />
|
|
||||||
</div>
|
|
||||||
<button class="px-4 py-2 rounded-md w-full bg-accent text-white border-0" type="submit">Create
|
|
||||||
category</button>
|
|
||||||
</form>
|
|
||||||
<span id="category-message"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="link-contents" class="hidden">
|
|
||||||
<h3>Add A link</h3>
|
|
||||||
<form id="link-form" action="/api/links" method="post"
|
|
||||||
class="flex flex-col gap-y-3 my-2 [&>div]:flex [&>div]:flex-col [&>div]:gap-1">
|
|
||||||
<div>
|
|
||||||
<label for="linkName">Name</label>
|
|
||||||
<input required type="text" name="name" id="linkName" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="linkDesc">Description (optional)</label>
|
|
||||||
<input type="text" name="description" id="linkDesc" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="linkURL">URL</label>
|
|
||||||
<input required type="url" name="url" id="linkURL" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="linkIcon">Icon</label>
|
|
||||||
<input required type="file" name="icon" id="linkIcon" accept="image/*" />
|
|
||||||
</div>
|
|
||||||
<button class="px-4 py-2 rounded-md w-full bg-accent text-white border-0" type="submit">Add
|
|
||||||
link</button>
|
|
||||||
</form>
|
|
||||||
<span id="link-message"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -125,9 +184,125 @@
|
|||||||
let modalContainer = document.getElementById("modal-container");
|
let modalContainer = document.getElementById("modal-container");
|
||||||
let modal = modalContainer.querySelector("div");
|
let modal = modalContainer.querySelector("div");
|
||||||
let pageElement = document.getElementById("blur-target");
|
let pageElement = document.getElementById("blur-target");
|
||||||
|
let iconUploader = document.getElementById("icon-upload");
|
||||||
let targetCategoryID = null;
|
let targetCategoryID = null;
|
||||||
let activeModal = null;
|
let activeModal = null;
|
||||||
|
|
||||||
|
// errpr check the form and add the invalid class if it's invalid
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submits a form to the given URL
|
||||||
|
* @param {Event} event - The event that triggered the function
|
||||||
|
* @param {string} url - The URL to submit the form to
|
||||||
|
* @param {"category" | "link"} target - The target to close the modal for
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async function submitRequest(event, url, target) {
|
||||||
|
event.preventDefault();
|
||||||
|
let data = new FormData(event.target);
|
||||||
|
|
||||||
|
let res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
body: data
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 201) {
|
||||||
|
closeModal(target);
|
||||||
|
document.getElementById(`${target}-form`).reset();
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
let json = await res.json();
|
||||||
|
document.getElementById(`${target}-message`).innerText = json.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the form for the given form
|
||||||
|
* @param {"category" | "link"} form - The form to initialize
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
function addErrorListener(form) {
|
||||||
|
document.getElementById(`${form}-form`).querySelector("button").addEventListener("click", (event) => {
|
||||||
|
document.getElementById(`${form}-form`).querySelectorAll("[required]").forEach((el) => {
|
||||||
|
el.classList.add("invalid:border-[#861024]!");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addErrorListener("link");
|
||||||
|
document.getElementById("link-form").addEventListener("submit", async (event) => {
|
||||||
|
await submitRequest(event, `/api/category/${targetCategoryID}/link`, "link");
|
||||||
|
});
|
||||||
|
|
||||||
|
addErrorListener("category");
|
||||||
|
document.getElementById("category-form").addEventListener("submit", async (event) => {
|
||||||
|
await submitRequest(event, `/api/category`, "category");
|
||||||
|
});
|
||||||
|
|
||||||
|
// when the background is clicked, close the modal
|
||||||
|
modalContainer.addEventListener("click", (event) => {
|
||||||
|
if (event.target === modalContainer) {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function selectIcon() {
|
||||||
|
iconUploader.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes a file and returns a data URL.
|
||||||
|
* @param {File} file The file to process.
|
||||||
|
*/
|
||||||
|
async function processFile(file) {
|
||||||
|
let reader = new FileReader();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
|
||||||
|
if (file.type === "image/svg+xml") {
|
||||||
|
reader.addEventListener("load", async (event) => {
|
||||||
|
let svgString = event.target.result;
|
||||||
|
|
||||||
|
console.log(svgString);
|
||||||
|
|
||||||
|
svgString = svgString.replaceAll("currentColor", "oklch(87% 0.015 286)");
|
||||||
|
|
||||||
|
console.log(svgString);
|
||||||
|
|
||||||
|
// turn svgString into a data URL
|
||||||
|
resolve("data:image/svg+xml;base64," + btoa(unescape(encodeURIComponent(svgString))));
|
||||||
|
})
|
||||||
|
|
||||||
|
reader.readAsText(file);
|
||||||
|
} else {
|
||||||
|
|
||||||
|
// these should be jpg, png, or webp
|
||||||
|
// make a DataURL out of it
|
||||||
|
reader.addEventListener("load", async (event) => {
|
||||||
|
resolve(event.target.result);
|
||||||
|
});
|
||||||
|
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetedImageElement = null;
|
||||||
|
iconUploader.addEventListener("change", async (event) => {
|
||||||
|
let file = event.target.files[0];
|
||||||
|
if (file === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetedImageElement === null) {
|
||||||
|
throw new Error("icon upload element was clicked, but no target image element was set");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(file);
|
||||||
|
|
||||||
|
let dataURL = await processFile(file);
|
||||||
|
targetedImageElement.src = dataURL;
|
||||||
|
});
|
||||||
|
|
||||||
function openModal(modalKind, categoryID) {
|
function openModal(modalKind, categoryID) {
|
||||||
activeModal = modalKind;
|
activeModal = modalKind;
|
||||||
targetCategoryID = categoryID;
|
targetCategoryID = categoryID;
|
||||||
@@ -137,7 +312,10 @@
|
|||||||
|
|
||||||
modalContainer.classList.add("is-visible");
|
modalContainer.classList.add("is-visible");
|
||||||
modal.classList.add("is-visible");
|
modal.classList.add("is-visible");
|
||||||
document.getElementById(modalKind + "-form").reset();
|
|
||||||
|
if (document.getElementById(modalKind + "-form") !== null) {
|
||||||
|
document.getElementById(modalKind + "-form").reset();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeModal() {
|
function closeModal() {
|
||||||
@@ -151,93 +329,482 @@
|
|||||||
activeModal = null;
|
activeModal = null;
|
||||||
}, 300)
|
}, 300)
|
||||||
|
|
||||||
document.getElementById(activeModal + "-form").querySelectorAll("[required]").forEach((el) => {
|
if (document.getElementById(activeModal + "-form") !== null) {
|
||||||
el.classList.remove("invalid:border-[#861024]!");
|
document.getElementById(activeModal + "-form").querySelectorAll("[required]").forEach((el) => {
|
||||||
});
|
el.classList.remove("invalid:border-[#861024]!");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
targetCategoryID = null;
|
targetCategoryID = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
modalContainer.addEventListener("click", (event) => {
|
let currentlyEditingLink = {
|
||||||
if (event.target === modalContainer) {
|
ID: null,
|
||||||
closeModal();
|
categoryID: null,
|
||||||
}
|
originalText: null,
|
||||||
});
|
originalDescription: null,
|
||||||
|
icon: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function editLink(linkID, categoryID) {
|
||||||
|
if (currentlyEditingLink.ID !== null) {
|
||||||
|
// cancel the edit if it's already in progress
|
||||||
|
cancelLinkEdit(currentlyEditingLink.ID, currentlyEditingCategory.categoryID);
|
||||||
|
}
|
||||||
|
|
||||||
|
let linkEl = document.querySelector(`[key=link-${linkID}]`);
|
||||||
|
let linkImg = linkEl.querySelector("div:first-child img");
|
||||||
|
let fileUploaderOverlay = linkImg.nextElementSibling;
|
||||||
|
let linkName = linkEl.querySelector("div:nth-child(2) h3");
|
||||||
|
let linkDesc = linkEl.querySelector("div:nth-child(2) p");
|
||||||
|
let editActions = linkEl.querySelector("[data-edit-actions]");
|
||||||
|
|
||||||
|
currentlyEditingLink.ID = linkID;
|
||||||
|
currentlyEditingCategory.categoryID = categoryID;
|
||||||
|
currentlyEditingLink.originalText = linkName.textContent;
|
||||||
|
currentlyEditingLink.originalDescription = linkDesc.textContent;
|
||||||
|
currentlyEditingLink.icon = linkImg.src;
|
||||||
|
|
||||||
|
console.log(currentlyEditingLink)
|
||||||
|
|
||||||
|
iconUploader.accept = "image/*";
|
||||||
|
targetedImageElement = linkImg;
|
||||||
|
|
||||||
|
editActions.querySelector("div").classList.add("hidden");
|
||||||
|
editActions.querySelector("div").classList.remove("flex");
|
||||||
|
|
||||||
|
editActions.querySelector("div:nth-child(2)").classList.remove("hidden");
|
||||||
|
editActions.querySelector("div:nth-child(2)").classList.add("flex");
|
||||||
|
|
||||||
|
fileUploaderOverlay.classList.remove("hidden");
|
||||||
|
fileUploaderOverlay.classList.add("flex");
|
||||||
|
|
||||||
|
replaceWithResizableInput(linkName);
|
||||||
|
replaceWithResizableTextarea(linkDesc);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmLinkEdit(linkID, categoryID) {
|
||||||
|
let linkEl = document.querySelector(`[key=link-${linkID}]`);
|
||||||
|
let linkImg = linkEl.querySelector("div:first-child img");
|
||||||
|
let fileUploaderOverlay = linkImg.nextElementSibling;
|
||||||
|
let linkNameInput = linkEl.querySelector("input");
|
||||||
|
let linkDescInput = linkEl.querySelector("textarea");
|
||||||
|
let editActions = linkEl.querySelector("[data-edit-actions]");
|
||||||
|
|
||||||
|
linkNameInput.value = linkNameInput.value.trim();
|
||||||
|
linkDescInput.value = linkDescInput.value.trim();
|
||||||
|
console.log(linkNameInput.value);
|
||||||
|
if (linkNameInput.value === "") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let formData = new FormData();
|
||||||
|
if (linkNameInput.value !== currentlyEditingLink.originalText) {
|
||||||
|
formData.append("name", linkNameInput.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (linkDescInput.value !== currentlyEditingLink.originalDescription) {
|
||||||
|
formData.append("description", linkDescInput.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (iconUploader.files.length > 0) {
|
||||||
|
formData.append("icon", iconUploader.files[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// nothing to update
|
||||||
|
if (formData.get("name") === null && formData.get("description") === null && formData.get("icon") === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteLink(linkID, categoryID) {
|
|
||||||
let res = await fetch(`/api/category/${categoryID}/link/${linkID}`, {
|
let res = await fetch(`/api/category/${categoryID}/link/${linkID}`, {
|
||||||
method: "DELETE"
|
method: "PATCH",
|
||||||
|
body: formData
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
let linkEl = document.querySelector(`[key="link-${linkID}"]`);
|
|
||||||
linkEl.remove();
|
iconUploader.value = "";
|
||||||
|
|
||||||
|
currentlyEditingLink.icon = null;
|
||||||
|
|
||||||
|
cancelLinkEdit(currentlyEditingLink.ID, currentlyEditingCategory.categoryID, linkNameInput.value || currentlyEditingLink.originalText, linkDescInput.value || currentlyEditingLink.originalDescription);
|
||||||
|
currentlyEditingLink.originalText = null;
|
||||||
|
currentlyEditingLink.originalDescription = null;
|
||||||
|
|
||||||
|
currentlyEditingLink.ID = null;
|
||||||
|
} else {
|
||||||
|
console.error("Failed to edit category");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteCategory(categoryID) {
|
async function cancelLinkEdit(linkID, categoryID, text = undefined, description = undefined) {
|
||||||
let res = await fetch(`/api/category/${categoryID}`, {
|
let linkEl = document.querySelector(`[key=link-${linkID}]`);
|
||||||
|
let linkInput = linkEl.querySelector("input");
|
||||||
|
let linkTextarea = linkEl.querySelector("textarea");
|
||||||
|
let linkImg = linkEl.querySelector("div:first-child img");
|
||||||
|
let fileUploaderOverlay = linkImg.nextElementSibling;
|
||||||
|
let editActions = linkEl.querySelector("[data-edit-actions]");
|
||||||
|
|
||||||
|
console.log(linkInput);
|
||||||
|
console.log(editActions);
|
||||||
|
|
||||||
|
if (currentlyEditingLink.icon !== null) {
|
||||||
|
linkImg.src = currentlyEditingLink.icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
editActions.querySelector("div").classList.remove("hidden");
|
||||||
|
editActions.querySelector("div").classList.add("flex");
|
||||||
|
|
||||||
|
editActions.querySelector("div:nth-child(2)").classList.add("hidden");
|
||||||
|
editActions.querySelector("div:nth-child(2)").classList.remove("flex");
|
||||||
|
|
||||||
|
fileUploaderOverlay.classList.add("hidden");
|
||||||
|
fileUploaderOverlay.classList.remove("flex");
|
||||||
|
|
||||||
|
if (text === undefined) {
|
||||||
|
text = currentlyEditingLink.originalText;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (description === undefined) {
|
||||||
|
description = currentlyEditingLink.originalDescription;
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreElementFromInput(linkInput, text);
|
||||||
|
restoreElementFromInput(linkTextarea, description);
|
||||||
|
|
||||||
|
currentlyEditingLink.ID = null;
|
||||||
|
targetedImageElement = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentlyDeletingLink = {
|
||||||
|
ID: null,
|
||||||
|
categoryID: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function deleteLink(linkID, categoryID) {
|
||||||
|
currentlyDeletingLink.ID = linkID;
|
||||||
|
currentlyDeletingLink.categoryID = categoryID;
|
||||||
|
|
||||||
|
let linkNameSpan = document.getElementById("link-name");
|
||||||
|
linkNameSpan.textContent = document.querySelector(`[key=link-${linkID}] h3`).textContent;
|
||||||
|
|
||||||
|
openModal("link-delete");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDeleteLink() {
|
||||||
|
let res = await fetch(`/api/category/${currentlyDeletingLink.categoryID}/link/${currentlyDeletingLink.ID}`, {
|
||||||
method: "DELETE"
|
method: "DELETE"
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
let categoryEl = document.querySelector(`[key="category-${categoryID}"]`);
|
let linkEl = document.querySelector(`[key="link-${currentlyDeletingLink.ID}"]`);
|
||||||
|
linkEl.remove();
|
||||||
|
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentlyEditingCategory = {
|
||||||
|
ID: null,
|
||||||
|
originalText: null,
|
||||||
|
icon: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function editCategory(categoryID) {
|
||||||
|
if (currentlyEditingCategory.ID !== null) {
|
||||||
|
// cancel the edit if it's already in progress
|
||||||
|
cancelCategoryEdit(currentlyEditingCategory.ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentlyEditingCategory.ID = categoryID;
|
||||||
|
|
||||||
|
let categoryEl = document.querySelector(`[key=category-${categoryID}]`);
|
||||||
|
let categoryName = categoryEl.querySelector("h2");
|
||||||
|
let categoryIcon = categoryEl.querySelector("div img");
|
||||||
|
let fileUploaderOverlay = categoryIcon.nextElementSibling;
|
||||||
|
let editActions = categoryEl.querySelector("[data-edit-actions]");
|
||||||
|
|
||||||
|
currentlyEditingCategory.originalText = categoryName.textContent;
|
||||||
|
currentlyEditingCategory.icon = categoryIcon.src;
|
||||||
|
|
||||||
|
iconUploader.accept = "image/svg+xml";
|
||||||
|
targetedImageElement = categoryIcon;
|
||||||
|
|
||||||
|
editActions.querySelector("div").classList.add("hidden");
|
||||||
|
editActions.querySelector("div").classList.remove("flex");
|
||||||
|
|
||||||
|
editActions.querySelector("div:nth-child(2)").classList.remove("hidden");
|
||||||
|
editActions.querySelector("div:nth-child(2)").classList.add("flex");
|
||||||
|
|
||||||
|
fileUploaderOverlay.classList.remove("hidden");
|
||||||
|
fileUploaderOverlay.classList.add("flex");
|
||||||
|
|
||||||
|
replaceWithResizableInput(categoryName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmCategoryEdit(categoryID) {
|
||||||
|
let categoryEl = document.querySelector(`[key=category-${categoryID}]`);
|
||||||
|
let categoryInput = categoryEl.querySelector("input");
|
||||||
|
let categoryIcon = categoryEl.querySelector("div img");
|
||||||
|
let fileUploaderOverlay = categoryIcon.nextElementSibling;
|
||||||
|
let editActions = categoryEl.querySelector("[data-edit-actions]");
|
||||||
|
|
||||||
|
if (categoryInput.value === "") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryInput.value = categoryInput.value.trim();
|
||||||
|
|
||||||
|
let formData = new FormData();
|
||||||
|
if (categoryInput.value !== currentlyEditingCategory.originalText) {
|
||||||
|
formData.append("name", categoryInput.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (iconUploader.files.length > 0) {
|
||||||
|
formData.append("icon", iconUploader.files[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// nothing to update
|
||||||
|
if (formData.get("name") === null && formData.get("icon") === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let res = await fetch(`/api/category/${categoryID}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 200) {
|
||||||
|
|
||||||
|
iconUploader.value = "";
|
||||||
|
|
||||||
|
cancelCategoryEdit(categoryID, categoryInput.value || currentlyEditingCategory.originalText);
|
||||||
|
|
||||||
|
currentlyEditingCategory.icon = null;
|
||||||
|
currentlyEditingCategory.originalText = null;
|
||||||
|
|
||||||
|
currentlyEditingCategory.ID = null;
|
||||||
|
} else {
|
||||||
|
console.error("Failed to edit category");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelCategoryEdit(categoryID, text = undefined) {
|
||||||
|
let categoryEl = document.querySelector(`[key=category-${categoryID}]`);
|
||||||
|
let categoryInput = categoryEl.querySelector("input");
|
||||||
|
let categoryIcon = categoryEl.querySelector("div img");
|
||||||
|
let fileUploaderOverlay = categoryIcon.nextElementSibling;
|
||||||
|
let editActions = categoryEl.querySelector("[data-edit-actions]");
|
||||||
|
|
||||||
|
console.log(categoryInput);
|
||||||
|
console.log(editActions);
|
||||||
|
|
||||||
|
if (currentlyEditingCategory.icon !== null) {
|
||||||
|
categoryIcon.src = currentlyEditingCategory.icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
editActions.querySelector("div").classList.remove("hidden");
|
||||||
|
editActions.querySelector("div").classList.add("flex");
|
||||||
|
|
||||||
|
editActions.querySelector("div:nth-child(2)").classList.add("hidden");
|
||||||
|
editActions.querySelector("div:nth-child(2)").classList.remove("flex");
|
||||||
|
|
||||||
|
fileUploaderOverlay.classList.remove("flex");
|
||||||
|
fileUploaderOverlay.classList.add("hidden");
|
||||||
|
|
||||||
|
restoreElementFromInput(categoryInput, text || currentlyEditingCategory.originalText);
|
||||||
|
|
||||||
|
currentlyEditingCategory.ID = null;
|
||||||
|
targetedImageElement = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentlyDeletingCategory = {
|
||||||
|
ID: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function deleteCategory(categoryID) {
|
||||||
|
currentlyDeletingCategory.ID = categoryID;
|
||||||
|
|
||||||
|
let categoryNameSpan = document.getElementById("category-name");
|
||||||
|
categoryNameSpan.textContent = document.querySelector(`[key=category-${categoryID}] h2`).textContent;
|
||||||
|
|
||||||
|
openModal("category-delete");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDeleteCategory() {
|
||||||
|
let res = await fetch(`/api/category/${currentlyDeletingCategory.ID}`, {
|
||||||
|
method: "DELETE"
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 200) {
|
||||||
|
let categoryEl = document.querySelector(`[key="category-${currentlyDeletingCategory.ID}"]`);
|
||||||
// get the next element and remove it (its the link grid)
|
// get the next element and remove it (its the link grid)
|
||||||
let nextEl = categoryEl.nextElementSibling;
|
let nextEl = categoryEl.nextElementSibling;
|
||||||
nextEl.remove();
|
nextEl.remove();
|
||||||
categoryEl.remove();
|
categoryEl.remove();
|
||||||
|
|
||||||
|
closeModal();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById("link-form").querySelector("button").addEventListener("click", (event) => {
|
/**
|
||||||
document.getElementById("link-form").querySelectorAll("[required]").forEach((el) => {
|
* Replaces an H2 element with a resizable input field that matches its initial text and styling.
|
||||||
el.classList.add("invalid:border-[#861024]!");
|
* @param {HTMLElement} targetEl The element to replace.
|
||||||
});
|
*/
|
||||||
});
|
function replaceWithResizableInput(targetEl) {
|
||||||
|
const originalText = targetEl.textContent;
|
||||||
|
const computedStyle = window.getComputedStyle(targetEl);
|
||||||
|
|
||||||
document.getElementById("link-form").addEventListener("submit", async (event) => {
|
const inputElement = document.createElement('input');
|
||||||
event.preventDefault();
|
inputElement.type = 'text';
|
||||||
let data = new FormData(event.target);
|
inputElement.value = originalText;
|
||||||
|
inputElement.className = 'resizable-input';
|
||||||
|
inputElement.placeholder = 'Enter title...';
|
||||||
|
inputElement.dataset.originalElementType = targetEl.tagName;
|
||||||
|
inputElement.dataset.originalClassName = targetEl.className;
|
||||||
|
|
||||||
let res = await fetch(`/api/category/${targetCategoryID}/link`, {
|
const stylesToCopy = [
|
||||||
method: "POST",
|
'font-family', 'font-size', 'font-weight', 'font-style', 'color',
|
||||||
body: data
|
'line-height', 'letter-spacing', 'text-transform', 'text-align',
|
||||||
|
'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
|
||||||
|
'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width',
|
||||||
|
'border-top-style', 'border-right-style', 'border-bottom-style', 'border-left-style',
|
||||||
|
'border-top-color', 'border-right-color', 'border-bottom-color', 'border-left-color',
|
||||||
|
'border-radius', 'box-sizing',
|
||||||
|
'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
|
||||||
|
'height'
|
||||||
|
];
|
||||||
|
|
||||||
|
stylesToCopy.forEach(prop => {
|
||||||
|
inputElement.style[prop] = computedStyle[prop];
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status === 201) {
|
inputElement.style.display = 'inline-block';
|
||||||
closeModal('link');
|
inputElement.style.backgroundColor = 'var(--color-base)';
|
||||||
document.getElementById("link-form").reset();
|
inputElement.style.border = '1px solid var(--color-highlight-sm)';
|
||||||
location.reload();
|
inputElement.style.borderRadius = '0.375rem';
|
||||||
} else {
|
inputElement.maxLength = 50;
|
||||||
let json = await res.json();
|
|
||||||
document.getElementById("category-message").innerText = json.message;
|
/**
|
||||||
|
* Function to measure the text width accurately and apply it to the input.
|
||||||
|
* @param {HTMLInputElement} inputEl The input element to resize.
|
||||||
|
*/
|
||||||
|
const resizeInput = (inputEl) => {
|
||||||
|
const tempSpan = document.createElement('span');
|
||||||
|
const currentInputComputedStyle = window.getComputedStyle(inputEl);
|
||||||
|
|
||||||
|
const textStylesToCopy = [
|
||||||
|
'font-family', 'font-size', 'font-weight', 'font-style', 'letter-spacing',
|
||||||
|
'text-transform', 'line-height'
|
||||||
|
];
|
||||||
|
textStylesToCopy.forEach(prop => {
|
||||||
|
tempSpan.style[prop] = currentInputComputedStyle[prop];
|
||||||
|
});
|
||||||
|
|
||||||
|
tempSpan.style.position = 'absolute';
|
||||||
|
tempSpan.style.visibility = 'hidden';
|
||||||
|
tempSpan.style.whiteSpace = 'nowrap';
|
||||||
|
tempSpan.textContent = inputEl.value === '' ? inputEl.placeholder || 'W' : inputEl.value;
|
||||||
|
|
||||||
|
document.body.appendChild(tempSpan);
|
||||||
|
let measuredTextWidth = tempSpan.offsetWidth;
|
||||||
|
document.body.removeChild(tempSpan);
|
||||||
|
|
||||||
|
// Add a small buffer for the caret and a bit of extra space
|
||||||
|
const caretBuffer = 10;
|
||||||
|
let finalWidth = measuredTextWidth + caretBuffer;
|
||||||
|
|
||||||
|
const minWidth = 100;
|
||||||
|
finalWidth = Math.max(finalWidth, minWidth);
|
||||||
|
|
||||||
|
if (currentInputComputedStyle.boxSizing === 'border-box') {
|
||||||
|
const hPadding = parseFloat(currentInputComputedStyle.paddingLeft) + parseFloat(currentInputComputedStyle.paddingRight);
|
||||||
|
const hBorder = parseFloat(currentInputComputedStyle.borderLeftWidth) + parseFloat(currentInputComputedStyle.borderRightWidth);
|
||||||
|
inputEl.style.width = (finalWidth + hPadding + hBorder) + 'px';
|
||||||
|
} else {
|
||||||
|
inputEl.style.width = finalWidth + 'px';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setTimeout(() => resizeInput(inputElement), 0);
|
||||||
|
inputElement.addEventListener('input', () => resizeInput(inputElement));
|
||||||
|
targetEl.parentNode.replaceChild(inputElement, targetEl);
|
||||||
|
|
||||||
|
inputElement.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceWithResizableTextarea(targetEl) {
|
||||||
|
const originalText = targetEl.textContent;
|
||||||
|
const computedStyle = window.getComputedStyle(targetEl);
|
||||||
|
|
||||||
|
const inputElement = document.createElement('textarea');
|
||||||
|
inputElement.value = originalText;
|
||||||
|
inputElement.className = 'resizable-input';
|
||||||
|
inputElement.placeholder = 'Enter title...';
|
||||||
|
inputElement.dataset.originalElementType = targetEl.tagName;
|
||||||
|
inputElement.dataset.originalClassName = targetEl.className;
|
||||||
|
|
||||||
|
const stylesToCopy = [
|
||||||
|
'font-family', 'font-size', 'font-weight', 'font-style', 'color',
|
||||||
|
'line-height', 'letter-spacing', 'text-transform', 'text-align',
|
||||||
|
'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
|
||||||
|
'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width',
|
||||||
|
'border-top-style', 'border-right-style', 'border-bottom-style', 'border-left-style',
|
||||||
|
'border-top-color', 'border-right-color', 'border-bottom-color', 'border-left-color',
|
||||||
|
'border-radius', 'box-sizing',
|
||||||
|
'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
|
||||||
|
'height'
|
||||||
|
];
|
||||||
|
|
||||||
|
stylesToCopy.forEach(prop => {
|
||||||
|
inputElement.style[prop] = computedStyle[prop];
|
||||||
|
});
|
||||||
|
|
||||||
|
inputElement.style.backgroundColor = 'var(--color-base)';
|
||||||
|
inputElement.style.border = '1px solid var(--color-highlight-sm)';
|
||||||
|
inputElement.style.borderRadius = '0.375rem';
|
||||||
|
inputElement.style.resize = 'none';
|
||||||
|
inputElement.style.overflow = 'hidden';
|
||||||
|
inputElement.style.width = '100%';
|
||||||
|
inputElement.style.outline = 'none';
|
||||||
|
inputElement.maxLength = 150;
|
||||||
|
|
||||||
|
function resize() {
|
||||||
|
inputElement.style.height = "0px";
|
||||||
|
inputElement.style.height = inputElement.scrollHeight + "px";
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById("category-form").querySelector("button").addEventListener("click", (event) => {
|
setTimeout(() => resize(), 0);
|
||||||
document.getElementById("category-form").querySelectorAll("[required]").forEach((el) => {
|
inputElement.addEventListener('input', () => resize());
|
||||||
el.classList.add("invalid:border-[#861024]!");
|
targetEl.parentNode.replaceChild(inputElement, targetEl);
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById("category-form").addEventListener("submit", async (event) => {
|
inputElement.focus();
|
||||||
event.preventDefault();
|
}
|
||||||
let data = new FormData(event.target);
|
|
||||||
|
|
||||||
let res = await fetch(`/api/category`, {
|
function restoreElementFromInput(inputEl, originalText) {
|
||||||
method: "POST",
|
const computedStyle = window.getComputedStyle(inputEl);
|
||||||
body: data
|
|
||||||
|
let elementType = inputEl.dataset.originalElementType;
|
||||||
|
const newElement = document.createElement(elementType);
|
||||||
|
newElement.textContent = originalText;
|
||||||
|
newElement.className = inputEl.dataset.originalClassName;
|
||||||
|
|
||||||
|
newElement.style.border = '1px solid #0000';
|
||||||
|
|
||||||
|
const stylesToCopy = [
|
||||||
|
'font-family', 'font-size', 'font-weight', 'font-style', 'color',
|
||||||
|
'line-height', 'letter-spacing', 'text-transform', 'text-align',
|
||||||
|
'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'box-sizing',
|
||||||
|
'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
|
||||||
|
'height'
|
||||||
|
];
|
||||||
|
|
||||||
|
stylesToCopy.forEach(prop => {
|
||||||
|
newElement.style[prop] = computedStyle[prop];
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status === 201) {
|
inputEl.parentNode.replaceChild(newElement, inputEl);
|
||||||
closeModal('category');
|
}
|
||||||
document.getElementById("category-form").reset();
|
|
||||||
location.reload();
|
|
||||||
} else {
|
|
||||||
let json = await res.json();
|
|
||||||
document.getElementById("link-message").innerText = json.message;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -83,10 +83,12 @@
|
|||||||
<div class="p-2.5 grid grid-cols-[repeat(auto-fill,_minmax(min(330px,_100%),_1fr))] gap-2">
|
<div class="p-2.5 grid grid-cols-[repeat(auto-fill,_minmax(min(330px,_100%),_1fr))] gap-2">
|
||||||
{{#each this.Links}} <a href="{{this.URL}}" class="link-card" draggable="false" target="_blank"
|
{{#each this.Links}} <a href="{{this.URL}}" class="link-card" draggable="false" target="_blank"
|
||||||
rel="noopener noreferrer">
|
rel="noopener noreferrer">
|
||||||
<img width="64" height="64" draggable="false" src="{{this.Icon}}" alt="{{this.Name}}" />
|
<div>
|
||||||
|
<img width="64" height="64" draggable="false" src="{{this.Icon}}" alt="{{this.Name}}" />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3>{{this.Name}}</h3>
|
<h3>{{this.Name}}</h3>
|
||||||
<p>{{this.Description}}</p>
|
<p class="min-h-5">{{this.Description}}</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{{else}}
|
{{else}}
|
||||||
|
|||||||
@@ -13,6 +13,6 @@
|
|||||||
"dev": "go generate ./src/; PASSPORT_DEV_MODE=true go run src/main.go",
|
"dev": "go generate ./src/; PASSPORT_DEV_MODE=true go run src/main.go",
|
||||||
"build": "go generate ./src/ && go build -tags netgo,prod -ldflags=\"-w -s\" -o passport"
|
"build": "go generate ./src/ && go build -tags netgo,prod -ldflags=\"-w -s\" -o passport"
|
||||||
},
|
},
|
||||||
"pattern": "src/**/*.{go,hbs,css,svg,png,jpg,jpeg,webp,woff2,ico,webp}",
|
"pattern": "src/**/*.{go,hbs,scss,svg,png,jpg,jpeg,webp,woff2,ico,webp}",
|
||||||
"shutdown_signal": "SIGINT"
|
"shutdown_signal": "SIGINT"
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user