6 Commits

Author SHA1 Message Date
Zoe
f6ffc90ec2 V0.3.2: Improved admin UI and performance galore
Some checks failed
Build and Push Docker Image to GHCR / build-and-push (push) Failing after 21m16s
This commit fixes a plethora of bugs related to the admin UI, as well as
dramatically improving the performance of in-place editing. Furthermore,
several server bugs and misc bugs have been fixed. The admin UI is now
entirely client side when adding, deleting, or editng a category or
link. Other internal improvements hasve also been made.
2025-10-02 00:16:53 -05:00
Zoe
75fe60b4c9 arm64 docker image builds
Some checks failed
Build and Push Docker Image to GHCR / build-and-push (push) Failing after 20m31s
2025-10-01 00:27:45 -05:00
Zoe
01a147d2d3 v0.3.1: More admin UI improvements and bug fixes
All checks were successful
Build and Push Docker Image to GHCR / build-and-push (push) Successful in 29s
This commit further refines the admin UI, and introduces a very SPA-like
creating process for links and categories. In-place editing has also
been improved, the styling is more correct and better formatted, as well
as having some cleaner code.

This PR also fixes a few bugs:
- Image uploads not being URL encoded, so special characters would break
  images
- If an image has exif, but no orientation tag, the image would be
  wrongfully rejected
- In-place editing forms were not correctly sized, and title inputs
  would not break with line breaks in the titles

This PR also greatly improves performance on the admin UI.
2025-09-30 19:45:58 -05:00
Zoe
462ed6491c Vastly overhaul admin UI
All checks were successful
Build and Push Docker Image to GHCR / build-and-push (push) Successful in 29s
Admin UI now has the ability to edit links that exist. Deleting items is
more accessible and asks for a confirmation before deleting. Link and
Category names as well as link descriptions now have a length limit
(todo: make it configurable?). Small bug fixes related to image saving
are also included in this commit.
2025-09-30 01:14:18 -05:00
Zoe
cd6ac6e771 Overhaul code org, and improve image uploading
This commit introduces breaking changes. It overhauls how and where
services are configured and placed in the codebase, as well as moving
the entire source into src/ It also changes how these integrations
are configured via environment variables. Old configs will still work
for now, but it is strongly suggested that you migrate your config.
2025-09-29 16:41:47 -05:00
Zoe
8c9ad40776 consistent and better styling 2025-09-29 16:37:27 +00:00
30 changed files with 2517 additions and 676 deletions

View File

@@ -27,6 +27,13 @@ jobs:
username: ${{ github.actor }}
password: ${{ env.OCI_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
@@ -40,3 +47,4 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64

2
.gitignore vendored
View File

@@ -5,4 +5,4 @@ public
zqdgr
# compiled via go prepare
assets/tailwind.css
src/assets/tailwind.css

19
.prettierrc Normal file
View File

@@ -0,0 +1,19 @@
{
"tabWidth": 4,
"useTabs": false,
"singleQuote": false,
"semi": true,
"trailingComma": "es5",
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "always",
"endOfLine": "lf",
"printWidth": 80,
"proseWrap": "preserve",
"quoteProps": "as-needed",
"requirePragma": false,
"embeddedLanguageFormatting": "auto",
"vueIndentScriptAndStyle": false,
"htmlWhitespaceSensitivity": "css",
"insertPragma": false
}

View File

@@ -3,21 +3,33 @@ FROM golang:1.25 AS builder
# build dependencies
RUN apt update && apt install -y upx
RUN curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.13/tailwindcss-linux-x64
RUN chmod +x tailwindcss-linux-x64
RUN mv tailwindcss-linux-x64 /usr/local/bin/tailwindcss
ARG TARGETARCH
RUN set -eux; \
echo "Building for architecture: ${TARGETARCH}"; \
case "${TARGETARCH}" in \
"amd64") \
arch_suffix='x64' ;; \
"arm64") \
arch_suffix='arm64' ;; \
*) \
echo "Unsupported architecture: ${TARGETARCH}" && exit 1 ;; \
esac; \
curl -sLO "https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.13/tailwindcss-linux-${arch_suffix}"; \
mv "tailwindcss-linux-${arch_suffix}" /usr/local/bin/tailwindcss; \
chmod +x /usr/local/bin/tailwindcss;
RUN go install github.com/juls0730/zqdgr@latest
WORKDIR /app
ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64
ARG TARGETARCH
ENV CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH}
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN zqdgr build
RUN upx passport

View File

@@ -58,34 +58,35 @@ You can then run the binary.
| -------------------------------------- | ------------------------------------------------------------------------------- | -------- | ------- |
| `PASSPORT_DEV_MODE` | Enables dev mode | false | false |
| `PASSPORT_ENABLE_PREFORK` | Enables preforking | false | false |
| `PASSPORT_ENABLE_WEATHER` | Enables weather data, see [Weather configuration](#weather-configuration) | false | false |
| `PASSPORT_ENABLE_UPTIME` | Enables uptime data, see [Uptime configuration](#uptime-configuration) | false | false |
| `PASSPORT_ADMIN_USERNAME` | The username for the admin dashboard | true |
| `PASSPORT_ADMIN_PASSWORD` | The password for the admin dashboard | 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 |
> [!NOTE]
> Currently passport only supports search using a GET request.
#### Weather configuration
The following only applies if you are using the OpenWeather 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.
| Environment Variable | Description | Required | Default |
| ----------------------------- | ------------------------------------------------------------------------- | -------- | -------------- |
| `OPENWEATHER_PROVIDER` | The weather provider to use, currently only `openweathermap` is supported | true | openweathermap |
| `OPENWEATHER_API_KEY` | The OpenWeather API key | true | |
| `OPENWEATHER_TEMP_UNITS` | The temperature units to use, either `metric` or `imperial` | false | metric |
| `OPENWEATHER_LAT` | The latitude of your location | true | |
| `OPENWEATHER_LON` | The longitude of your location | true | |
| `OPENWEATHER_UPDATE_INTERVAL` | The interval in minutes to update the weather data | false | 15 |
| ------------------------- | ------------------------------------------------------------------------- | -------- | -------------- |
| `WEATHER_PROVIDER` | The weather provider to use, currently only `openweathermap` is supported | false | openweathermap |
| `WEATHER_API_KEY` | The OpenWeather API key | true | |
| `WEATHER_TEMP_UNITS` | The temperature units to use, either `metric` or `imperial` | false | metric |
| `WEATHER_LAT` | The latitude of your location | true | |
| `WEATHER_LON` | The longitude of your location | true | |
| `WEATHER_UPDATE_INTERVAL` | The interval in minutes to update the weather data | false | 15 |
#### Uptime configuration
The following only applies if you are using the UptimeRobot integration.
The uptime integration is optional, and will be enabled automatically if you provide an API key. The following only applies if you are using the UptimeRobot integration.
| Environment Variable | Description | Required | Default |
| ----------------------------- | ------------------------------------------------- | -------- | ------- |
| `UPTIMEROBOT_API_KEY` | The UptimeRobot API key | true | |
| `UPTIMEROBOT_UPDATE_INTERVAL` | The interval in seconds to update the uptime data | false | 300 |
| ------------------------ | ------------------------------------------------- | -------- | ------- |
| `UPTIME_API_KEY` | The UptimeRobot API key | true | |
| `UPTIME_UPDATE_INTERVAL` | The interval in seconds to update the uptime data | false | 300 |
### Adding links and categories

8
go.mod
View File

@@ -4,7 +4,11 @@ go 1.25.0
require (
github.com/HugoSmits86/nativewebp v1.2.0
github.com/NarmadaWeb/gonify/v3 v3.0.0-beta
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
modernc.org/sqlite v1.39.0
)
@@ -16,10 +20,10 @@ require (
github.com/philhofer/fwd v1.2.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sirupsen/logrus v1.8.1 // indirect
github.com/tdewolff/parse/v2 v2.8.3 // indirect
github.com/tinylib/msgp v1.4.0 // indirect
golang.org/x/crypto v0.42.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/image v0.24.0 // indirect
golang.org/x/net v0.44.0 // indirect
golang.org/x/text v0.29.0 // indirect
modernc.org/libc v1.66.3 // indirect
@@ -41,8 +45,8 @@ require (
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
github.com/rivo/uniseg v0.2.0 // indirect
github.com/tdewolff/minify/v2 v2.24.3 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.66.0 // indirect
golang.org/x/sys v0.36.0 // indirect

16
go.sum
View File

@@ -1,5 +1,7 @@
github.com/HugoSmits86/nativewebp v1.2.0 h1:XJtXeTg7FsOi9VB1elQYZy3n6VjYLqofSr3gGRLUOp4=
github.com/HugoSmits86/nativewebp v1.2.0/go.mod h1:YNQuWenlVmSUUASVNhTDwf4d7FwYQGbGhklC8p72Vr8=
github.com/NarmadaWeb/gonify/v3 v3.0.0-beta h1:tNj6Rq9S3UUnF2800h6Ns7wmx+q7MwoZBVD24fPCSlo=
github.com/NarmadaWeb/gonify/v3 v3.0.0-beta/go.mod h1:AoLhZCGC/9XGqOE+0amArp/dFIZSfZSvbyPI/IbQ7Q0=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
@@ -7,6 +9,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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
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/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
@@ -43,8 +47,6 @@ github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZ
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -53,6 +55,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/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
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/go.mod h1:6khjYnkx73f7VQU7wjcFS9DFjs+59naVWJv1TB7qdOI=
github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
@@ -62,6 +66,12 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8=
github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tdewolff/minify/v2 v2.24.3 h1:BaKgWSFLKbKDiUskbeRgbe2n5d1Ci1x3cN/eXna8zOA=
github.com/tdewolff/minify/v2 v2.24.3/go.mod h1:1JrCtoZXaDbqioQZfk3Jdmr0GPJKiU7c1Apmb+7tCeE=
github.com/tdewolff/parse/v2 v2.8.3 h1:5VbvtJ83cfb289A1HzRA9sf02iT8YyUwN84ezjkdY1I=
github.com/tdewolff/parse/v2 v2.8.3/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE=
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/tinylib/msgp v1.4.0 h1:SYOeDRiydzOw9kSiwdYp9UcBgPFtLU2WDHaJXyHruf8=
github.com/tinylib/msgp v1.4.0/go.mod h1:cvjFkb4RiC8qSBOPMGPSzSAx47nAsfhLVTCZZNuHv5o=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
@@ -76,6 +86,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/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/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/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
@@ -88,6 +99,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.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
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/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=

View File

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -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
@@ -6,7 +6,6 @@ import (
"bytes"
"database/sql"
"embed"
"encoding/json"
"errors"
"fmt"
"image"
@@ -23,44 +22,62 @@ import (
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/HugoSmits86/nativewebp"
"github.com/NarmadaWeb/gonify/v3"
"github.com/caarlos0/env/v11"
"github.com/disintegration/imaging"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/compress"
"github.com/gofiber/fiber/v3/middleware/helmet"
"github.com/gofiber/fiber/v3/middleware/static"
"github.com/gofiber/template/handlebars/v2"
"github.com/google/uuid"
"github.com/joho/godotenv"
"github.com/juls0730/passport/middleware"
"github.com/nfnt/resize"
"github.com/juls0730/passport/src/middleware"
"github.com/juls0730/passport/src/services"
"github.com/rwcarlsen/goexif/exif"
"github.com/rwcarlsen/goexif/tiff"
"golang.org/x/image/draw"
_ "modernc.org/sqlite"
)
//go:embed assets/** templates/** schema.sql
//go:embed assets/** templates/** schema.sql scripts/**.js styles/**.css
var embeddedAssets embed.FS
var devContent = `<script>
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) => {
if (event.data === 'refresh') {
console.log('Got refresh signal');
let attempts = 0;
let delay = 100;
async function testPage() {
try {
let res = await fetch(window.location.href)
} catch (error) {
console.error(error);
setTimeout(testPage, 300);
if (attempts > 5) {
return;
}
setTimeout(testPage, delay);
// exponential backoff
attempts++;
delay = 100 * Math.pow(2, attempts);
return;
}
window.location.reload();
}
testPage();
setTimeout(testPage, 150);
}
});
</script>`
@@ -70,37 +87,15 @@ var (
insertLinkStmt *sql.Stmt
)
type WeatherProvider string
const (
OpenWeatherMap WeatherProvider = "openweathermap"
)
type WeatherConfig struct {
Provider WeatherProvider `env:"OPENWEATHER_PROVIDER" envDefault:"openweathermap"`
OpenWeather struct {
APIKey string `env:"OPENWEATHER_API_KEY"`
Units string `env:"OPENWEATHER_TEMP_UNITS" envDefault:"metric"`
Lat float64 `env:"OPENWEATHER_LAT"`
Lon float64 `env:"OPENWEATHER_LON"`
}
UpdateInterval int `env:"OPENWEATHER_UPDATE_INTERVAL" envDefault:"15"`
}
type UptimeConfig struct {
APIKey string `env:"UPTIMEROBOT_API_KEY"`
UpdateInterval int `env:"UPTIMEROBOT_UPDATE_INTERVAL" envDefault:"300"`
}
type Config struct {
DevMode bool `env:"PASSPORT_DEV_MODE" envDefault:"false"`
Prefork bool `env:"PASSPORT_ENABLE_PREFORK" envDefault:"false"`
WeatherEnabled bool `env:"PASSPORT_ENABLE_WEATHER" envDefault:"false"`
Weather *WeatherConfig
WeatherAPIKey string `env:"PASSPORT_WEATHER_API_KEY"`
Weather *services.WeatherConfig
UptimeEnabled bool `env:"PASSPORT_ENABLE_UPTIME" envDefault:"false"`
Uptime *UptimeConfig
UptimeAPIKey string `env:"PASSPORT_UPTIME_API_KEY"`
Uptime *services.UptimeConfig
Admin struct {
Username string `env:"PASSPORT_ADMIN_USERNAME"`
@@ -111,6 +106,11 @@ type Config struct {
URL string `env:"PASSPORT_SEARCH_PROVIDER"`
Query string `env:"PASSPORT_SEARCH_PROVIDER_QUERY_PARAM" envDefault:"q"`
}
Depricated struct {
WeatherEnabled bool `env:"PASSPORT_ENABLE_WEATHER" envDefault:"false"`
UptimeEnabled bool `env:"PASSPORT_ENABLE_UPTIME" envDefault:"false"`
}
}
func ParseConfig() (*Config, error) {
@@ -121,18 +121,46 @@ func ParseConfig() (*Config, error) {
return nil, err
}
if config.WeatherEnabled {
config.Weather = &WeatherConfig{}
if config.WeatherAPIKey != "" {
config.Weather = &services.WeatherConfig{
APIKey: config.WeatherAPIKey,
}
if err := env.Parse(config.Weather); err != nil {
return nil, err
}
} else if config.Depricated.WeatherEnabled {
slog.Warn("Your configuration file contains depricated Weather settings. Please update your configuration file!")
depricatedWeatherConfig := &services.DepricatedWeatherConfig{}
if err := env.Parse(depricatedWeatherConfig); err != nil {
return nil, err
}
if config.UptimeEnabled {
config.Uptime = &UptimeConfig{}
config.Weather = &services.WeatherConfig{}
config.Weather.Provider = depricatedWeatherConfig.OpenWeather.Provider
config.Weather.APIKey = depricatedWeatherConfig.OpenWeather.APIKey
config.Weather.Units = depricatedWeatherConfig.OpenWeather.Units
config.Weather.Lat = depricatedWeatherConfig.OpenWeather.Lat
config.Weather.Lon = depricatedWeatherConfig.OpenWeather.Lon
config.Weather.UpdateInterval = depricatedWeatherConfig.UpdateInterval
}
if config.UptimeAPIKey != "" {
config.Uptime = &services.UptimeConfig{
APIKey: config.UptimeAPIKey,
}
if err := env.Parse(config.Uptime); err != nil {
return nil, err
}
} else if config.Depricated.UptimeEnabled {
slog.Warn("Your configuration file contains depricated Uptime settings. Please update your configuration file!")
depricatedUptimeConfig := &services.DepricatedUptimeConfig{}
if err := env.Parse(depricatedUptimeConfig); err != nil {
return nil, err
}
config.Uptime = &services.UptimeConfig{}
config.Uptime.APIKey = depricatedUptimeConfig.APIKey
config.Uptime.UpdateInterval = depricatedUptimeConfig.UpdateInterval
}
return &config, nil
@@ -141,8 +169,8 @@ func ParseConfig() (*Config, error) {
type App struct {
*Config
*CategoryManager
*WeatherCache
*UptimeManager
*services.WeatherManager
*services.UptimeManager
db *sql.DB
}
@@ -199,245 +227,66 @@ func NewApp(dbPath string, options map[string]any) (*App, error) {
return nil, err
}
var weatherCache *WeatherCache
if config.WeatherEnabled {
weatherCache = NewWeatherCache(config.Weather)
var weatherCache *services.WeatherManager
if config.WeatherAPIKey != "" {
weatherCache = services.NewWeatherManager(config.Weather)
}
var uptimeManager *UptimeManager
if config.UptimeEnabled {
uptimeManager = NewUptimeManager(config.Uptime)
var uptimeManager *services.UptimeManager
if config.UptimeAPIKey != "" {
uptimeManager = services.NewUptimeManager(config.Uptime)
}
return &App{
Config: config,
WeatherCache: weatherCache,
WeatherManager: weatherCache,
CategoryManager: categoryManager,
UptimeManager: uptimeManager,
db: db,
}, nil
}
type UptimeRobotSite struct {
FriendlyName string `json:"friendly_name"`
Url string `json:"url"`
Status int `json:"status"`
}
type UptimeManager struct {
sites []UptimeRobotSite
lastUpdate time.Time
mutex sync.RWMutex
updateChan chan struct{}
updateInterval int
apiKey string
}
func NewUptimeManager(config *UptimeConfig) *UptimeManager {
if config.APIKey == "" {
log.Fatalln("UptimeRobot API Key is required!")
return nil
func CropToCenter(img image.Image, outputSize int) (image.Image, error) {
if img == nil {
return nil, fmt.Errorf("input image is nil")
}
if outputSize <= 0 {
return nil, fmt.Errorf("output size must be positive")
}
updateInterval := config.UpdateInterval
if updateInterval < 1 {
updateInterval = 300
srcBounds := img.Bounds()
srcWidth := srcBounds.Dx()
srcHeight := srcBounds.Dy()
squareSide := min(srcWidth, srcHeight)
cropX := (srcWidth - squareSide) / 2
cropY := (srcHeight - squareSide) / 2
srcCropRect := image.Rect(cropX, cropY, cropX+squareSide, cropY+squareSide)
croppedSquareImg := image.NewRGBA(image.Rect(0, 0, squareSide, squareSide))
draw.Draw(croppedSquareImg, croppedSquareImg.Rect, img, srcCropRect.Min, draw.Src)
if squareSide == outputSize {
return croppedSquareImg, nil
}
uptimeManager := &UptimeManager{
updateChan: make(chan struct{}),
updateInterval: updateInterval,
apiKey: config.APIKey,
sites: []UptimeRobotSite{},
}
outputImg := image.NewRGBA(image.Rect(0, 0, outputSize, outputSize))
go uptimeManager.updateWorker()
draw.CatmullRom.Scale(outputImg, outputImg.Rect, croppedSquareImg, croppedSquareImg.Bounds(), draw.Src, nil)
uptimeManager.updateChan <- struct{}{}
return uptimeManager
return outputImg, nil
}
func (u *UptimeManager) getUptime() []UptimeRobotSite {
u.mutex.RLock()
defer u.mutex.RUnlock()
return u.sites
}
func (u *UptimeManager) updateWorker() {
ticker := time.NewTicker(time.Duration(u.updateInterval) * time.Second)
defer ticker.Stop()
for {
select {
case <-u.updateChan:
u.update()
case <-ticker.C:
u.update()
}
}
}
type UptimeRobotResponse struct {
Monitors []UptimeRobotSite `json:"monitors"`
}
func (u *UptimeManager) update() {
resp, err := http.Post("https://api.uptimerobot.com/v2/getMonitors?api_key="+u.apiKey, "application/json", nil)
func UploadFile(file *multipart.FileHeader, contentType string, c fiber.Ctx) (string, error) {
fileId, err := uuid.NewV7()
if err != nil {
fmt.Printf("Error fetching uptime data: %v\n", err)
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Printf("Error reading response: %v\n", err)
return
return "", err
}
var monitors UptimeRobotResponse
if err := json.Unmarshal(body, &monitors); err != nil {
fmt.Printf("Error parsing uptime data: %v\n", err)
return
}
fileName := fmt.Sprintf("%s.%s", fileId.String(), filepath.Ext(file.Filename))
u.mutex.Lock()
u.sites = monitors.Monitors
u.lastUpdate = time.Now()
u.mutex.Unlock()
}
type OpenWeatherResponse struct {
Weather []struct {
Name string `json:"main"`
IconId string `json:"icon"`
} `json:"weather"`
Main struct {
Temp float64 `json:"temp"`
} `json:"main"`
Code int `json:"cod"`
Message string `json:"message"`
}
type WeatherData struct {
Temperature float64
WeatherText string
Icon string
}
type WeatherCache struct {
data *WeatherData
lastUpdate time.Time
mutex sync.RWMutex
updateChan chan struct{}
tempUnits string
updateInterval int
apiKey string
lat float64
lon float64
}
func NewWeatherCache(config *WeatherConfig) *WeatherCache {
if config.Provider != OpenWeatherMap {
log.Fatalln("Only OpenWeatherMap is supported!")
return nil
}
if config.OpenWeather.APIKey == "" {
log.Fatalln("An API Key required for OpenWeather!")
return nil
}
updateInterval := config.UpdateInterval
if updateInterval < 1 {
updateInterval = 15
}
units := config.OpenWeather.Units
if units == "" {
units = "metric"
}
cache := &WeatherCache{
data: &WeatherData{},
updateChan: make(chan struct{}),
tempUnits: units,
updateInterval: updateInterval,
apiKey: config.OpenWeather.APIKey,
lat: config.OpenWeather.Lat,
lon: config.OpenWeather.Lon,
}
go cache.weatherWorker()
cache.updateChan <- struct{}{}
return cache
}
func (c *WeatherCache) GetWeather() WeatherData {
c.mutex.RLock()
defer c.mutex.RUnlock()
return *c.data
}
func (c *WeatherCache) weatherWorker() {
ticker := time.NewTicker(time.Duration(c.updateInterval) * time.Minute)
defer ticker.Stop()
for {
select {
case <-c.updateChan:
c.updateWeather()
case <-ticker.C:
c.updateWeather()
}
}
}
func (c *WeatherCache) updateWeather() {
url := fmt.Sprintf("https://api.openweathermap.org/data/2.5/weather?lat=%f&lon=%f&appid=%s&units=%s",
c.lat, c.lon, c.apiKey, c.tempUnits)
resp, err := http.Get(url)
if err != nil {
fmt.Printf("Error fetching weather: %v\n", err)
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Printf("Error reading response: %v\n", err)
return
}
var weatherResp OpenWeatherResponse
if err := json.Unmarshal(body, &weatherResp); err != nil {
fmt.Printf("Error parsing weather data: %v\n", err)
return
}
// if the request failed
if weatherResp.Code != 200 {
// if there is no pre-existing data in the cache
if c.data.WeatherText == "" {
log.Fatalf("Fetching the weather data failed!\n%s\n", weatherResp.Message)
} else {
return
}
}
c.mutex.Lock()
c.data.Temperature = weatherResp.Main.Temp
c.data.WeatherText = weatherResp.Weather[0].Name
c.data.Icon = weatherResp.Weather[0].IconId
c.lastUpdate = time.Now()
c.mutex.Unlock()
}
func UploadFile(file *multipart.FileHeader, fileName, contentType string, c fiber.Ctx) (string, error) {
srcFile, err := file.Open()
if err != nil {
return "", err
@@ -458,16 +307,67 @@ func UploadFile(file *multipart.FileHeader, fileName, contentType string, c fibe
return "", errors.New("unsupported file type")
}
if contentType != "image/svg+xml" {
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 {
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"
iconPath := filepath.Join(assetsDir, fileName)
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
}
} else {
@@ -477,11 +377,9 @@ func UploadFile(file *multipart.FileHeader, fileName, contentType string, c fibe
}
defer outFile.Close()
resizedImg := resize.Resize(64, 0, img, resize.MitchellNetravali)
var buf bytes.Buffer
options := &nativewebp.Options{}
if err := nativewebp.Encode(&buf, resizedImg, options); err != nil {
if err := nativewebp.Encode(&buf, img, options); err != nil {
return "", err
}
@@ -808,16 +706,23 @@ func main() {
log.Fatal(err)
}
css, err := fs.ReadFile(embeddedAssets, "assets/tailwind.css")
if err != nil {
log.Fatal(err)
}
engine := handlebars.NewFileSystem(http.FS(templatesDir), ".hbs")
engine.AddFunc("inlineCSS", func() string {
return string(css)
engine.AddFunc("embedFile", func(fileToEmbed string) string {
content, err := fs.ReadFile(embeddedAssets, fileToEmbed)
if err != nil {
return ""
}
fileExtension := filepath.Ext(fileToEmbed)
switch fileExtension {
case ".js":
return fmt.Sprintf("<script>%s</script>", content)
case ".css":
return fmt.Sprintf("<style>%s</style>", content)
default:
return string(content)
}
})
engine.AddFunc("devContent", func() string {
@@ -827,10 +732,6 @@ func main() {
return ""
})
engine.AddFunc("eq", func(a, b any) bool {
return a == b
})
router := fiber.New(fiber.Config{
Views: engine,
})
@@ -842,6 +743,17 @@ func main() {
return c.Redirect().To("/assets/favicon.ico")
})
router.Use(compress.New(compress.Config{
Level: compress.LevelBestSpeed,
}))
router.Use(gonify.New(gonify.Config{
MinifySVG: !app.DevMode,
MinifyCSS: !app.DevMode,
MinifyJS: !app.DevMode,
MinifyHTML: !app.DevMode,
}))
router.Use("/", static.New("./public", static.Config{
Browse: false,
MaxAge: 31536000,
@@ -859,8 +771,8 @@ func main() {
"Categories": app.CategoryManager.GetCategories(),
}
if app.Config.WeatherEnabled {
weather := app.WeatherCache.GetWeather()
if app.Config.WeatherAPIKey != "" {
weather := app.WeatherManager.GetWeather()
renderData["WeatherData"] = fiber.Map{
"Temp": weather.Temperature,
@@ -869,8 +781,8 @@ func main() {
}
}
if app.Config.UptimeEnabled {
renderData["UptimeData"] = app.UptimeManager.getUptime()
if app.Config.UptimeAPIKey != "" {
renderData["UptimeData"] = app.UptimeManager.GetUptime()
}
return c.Render("views/index", renderData, "layouts/main")
@@ -932,7 +844,7 @@ func main() {
return c.Render("views/admin/index", fiber.Map{
"Categories": app.CategoryManager.GetCategories(),
}, "layouts/main")
}, "layouts/admin")
})
api := router.Group("/api")
@@ -961,6 +873,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")
if err != nil || file == nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
@@ -981,17 +901,13 @@ func main() {
})
}
filename := fmt.Sprintf("%d_%s.svg", time.Now().Unix(), strings.ReplaceAll(req.Name, " ", "_"))
iconPath, err := UploadFile(file, filename, contentType, c)
iconPath, err := UploadFile(file, contentType, c)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"message": "Failed to upload file, please try again!",
})
}
UploadFile(file, iconPath, contentType, c)
category, err := app.CategoryManager.CreateCategory(Category{
Name: req.Name,
Icon: iconPath,
@@ -1028,6 +944,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)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
@@ -1061,9 +994,7 @@ func main() {
})
}
filename := fmt.Sprintf("%d_%s.webp", time.Now().Unix(), strings.ReplaceAll(req.Name, " ", "_"))
iconPath, err := UploadFile(file, filename, contentType, c)
iconPath, err := UploadFile(file, contentType, c)
if err != nil {
slog.Error("Failed to upload file", "error", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
@@ -1071,8 +1002,6 @@ func main() {
})
}
UploadFile(file, iconPath, contentType, c)
link, err := app.CategoryManager.CreateLink(app.CategoryManager.db, Link{
CategoryID: categoryID,
Name: req.Name,
@@ -1093,6 +1022,241 @@ 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
iconPath, err := UploadFile(file, 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
iconPath, err := UploadFile(file, 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 {
linkID, err := strconv.ParseInt(c.Params("linkID"), 10, 64)
if err != nil {

1106
src/scripts/admin.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,111 @@
package services
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"sync"
"time"
)
type DepricatedUptimeConfig struct {
APIKey string `env:"UPTIMEROBOT_API_KEY"`
UpdateInterval int `env:"UPTIMEROBOT_UPDATE_INTERVAL" envDefault:"300"`
}
type UptimeConfig struct {
APIKey string
UpdateInterval int `env:"UPTIME_UPDATE_INTERVAL" envDefault:"300"`
}
type UptimeRobotSite struct {
FriendlyName string `json:"friendly_name"`
Url string `json:"url"`
Status int `json:"status"`
}
type UptimeManager struct {
sites []UptimeRobotSite
lastUpdate time.Time
mutex sync.RWMutex
updateChan chan struct{}
updateInterval int
apiKey string
}
func NewUptimeManager(config *UptimeConfig) *UptimeManager {
if config.APIKey == "" {
log.Fatalln("UptimeRobot API Key is required!")
return nil
}
updateInterval := config.UpdateInterval
if updateInterval < 1 {
updateInterval = 300
}
uptimeManager := &UptimeManager{
updateChan: make(chan struct{}),
updateInterval: updateInterval,
apiKey: config.APIKey,
sites: []UptimeRobotSite{},
}
go uptimeManager.updateWorker()
uptimeManager.updateChan <- struct{}{}
return uptimeManager
}
func (u *UptimeManager) GetUptime() []UptimeRobotSite {
u.mutex.RLock()
defer u.mutex.RUnlock()
return u.sites
}
func (u *UptimeManager) updateWorker() {
ticker := time.NewTicker(time.Duration(u.updateInterval) * time.Second)
defer ticker.Stop()
for {
select {
case <-u.updateChan:
u.update()
case <-ticker.C:
u.update()
}
}
}
type UptimeRobotResponse struct {
Monitors []UptimeRobotSite `json:"monitors"`
}
func (u *UptimeManager) update() {
resp, err := http.Post("https://api.uptimerobot.com/v2/getMonitors?api_key="+u.apiKey, "application/json", nil)
if err != nil {
fmt.Printf("Error fetching uptime data: %v\n", err)
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Printf("Error reading response: %v\n", err)
return
}
var monitors UptimeRobotResponse
if err := json.Unmarshal(body, &monitors); err != nil {
fmt.Printf("Error parsing uptime data: %v\n", err)
return
}
u.mutex.Lock()
u.sites = monitors.Monitors
u.lastUpdate = time.Now()
u.mutex.Unlock()
}

View File

@@ -0,0 +1,158 @@
package services
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"sync"
"time"
)
type WeatherProvider string
const (
OpenWeatherMap WeatherProvider = "openweathermap"
)
type DepricatedWeatherConfig struct {
OpenWeather struct {
Provider WeatherProvider `env:"OPENWEATHER_PROVIDER" envDefault:"openweathermap"`
APIKey string `env:"OPENWEATHER_API_KEY"`
Units string `env:"OPENWEATHER_TEMP_UNITS" envDefault:"metric"`
Lat float64 `env:"OPENWEATHER_LAT"`
Lon float64 `env:"OPENWEATHER_LON"`
}
UpdateInterval int `env:"OPENWEATHER_UPDATE_INTERVAL" envDefault:"15"`
}
type WeatherConfig struct {
Provider WeatherProvider `env:"WEATHER_PROVIDER" envDefault:"openweathermap"`
APIKey string `env:"WEATHER_API_KEY"`
Units string `env:"WEATHER_TEMP_UNITS" envDefault:"metric"`
Lat float64 `env:"WEATHER_LAT"`
Lon float64 `env:"WEATHER_LON"`
UpdateInterval int `env:"WEATHER_UPDATE_INTERVAL" envDefault:"15"`
}
type OpenWeatherResponse struct {
Weather []struct {
Name string `json:"main"`
IconId string `json:"icon"`
} `json:"weather"`
Main struct {
Temp float64 `json:"temp"`
} `json:"main"`
Code int `json:"cod"`
Message string `json:"message"`
}
type WeatherData struct {
Temperature float64
WeatherText string
Icon string
}
type WeatherManager struct {
data *WeatherData
lastUpdate time.Time
mutex sync.RWMutex
updateChan chan struct{}
config *WeatherConfig
}
func NewWeatherManager(config *WeatherConfig) *WeatherManager {
if config.Provider != OpenWeatherMap {
log.Fatalln("Only OpenWeatherMap is supported!")
return nil
}
if config.APIKey == "" {
log.Fatalln("An API Key required for OpenWeather!")
return nil
}
updateInterval := config.UpdateInterval
if updateInterval < 1 {
updateInterval = 15
}
units := config.Units
if units == "" {
units = "metric"
}
cache := &WeatherManager{
data: &WeatherData{},
updateChan: make(chan struct{}),
config: config,
}
go cache.weatherWorker()
cache.updateChan <- struct{}{}
return cache
}
func (c *WeatherManager) GetWeather() WeatherData {
c.mutex.RLock()
defer c.mutex.RUnlock()
return *c.data
}
func (c *WeatherManager) weatherWorker() {
ticker := time.NewTicker(time.Duration(c.config.UpdateInterval) * time.Minute)
defer ticker.Stop()
for {
select {
case <-c.updateChan:
c.updateWeather()
case <-ticker.C:
c.updateWeather()
}
}
}
func (c *WeatherManager) updateWeather() {
url := fmt.Sprintf("https://api.openweathermap.org/data/2.5/weather?lat=%f&lon=%f&appid=%s&units=%s",
c.config.Lat, c.config.Lon, c.config.APIKey, c.config.Units)
resp, err := http.Get(url)
if err != nil {
fmt.Printf("Error fetching weather: %v\n", err)
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Printf("Error reading response: %v\n", err)
return
}
var weatherResp OpenWeatherResponse
if err := json.Unmarshal(body, &weatherResp); err != nil {
fmt.Printf("Error parsing weather data: %v\n", err)
return
}
// if the request failed
if weatherResp.Code != 200 {
// if there is no pre-existing data in the cache
if c.data.WeatherText == "" {
log.Fatalf("Fetching the weather data failed!\n%s\n", weatherResp.Message)
} else {
return
}
}
c.mutex.Lock()
c.data.Temperature = weatherResp.Main.Temp
c.data.WeatherText = weatherResp.Weather[0].Name
c.data.Icon = weatherResp.Weather[0].IconId
c.lastUpdate = time.Now()
c.mutex.Unlock()
}

70
src/styles/adminUi.css Normal file
View File

@@ -0,0 +1,70 @@
.modal-bg {
visibility: hidden;
opacity: 0;
}
.modal-bg.is-visible {
visibility: visible;
opacity: 1;
}
.modal {
opacity: 0;
}
.modal.is-visible {
opacity: 1;
}
@media (prefers-reduced-motion: no-preference) {
.modal-bg {
visibility: hidden;
opacity: 0;
transition: opacity 0.3s ease, visibility 0s 0.3s;
transition-timing-function: cubic-bezier(0.45, 0, 0.55, 1);
}
.modal-bg.is-visible {
visibility: visible;
opacity: 1;
transition-delay: 0s;
}
.modal {
opacity: 0;
transform: translateY(20px) scale(0.95);
transition: opacity 0.3s ease, transform 0.3s ease;
transition-timing-function: cubic-bezier(0.45, 0, 0.55, 1);
}
.modal.is-visible {
opacity: 1;
visibility: visible;
transform: translateY(0) scale(1);
transition-delay: 0s;
}
}
.action-button {
display: flex;
width: fit-content;
height: fit-content;
padding: 0.25rem;
background-color: var(--color-highlight-sm);
border: 1px solid color-mix(in srgb, var(--color-highlight) 70%, #0000);
border-radius: 9999px;
box-shadow: var(--shadow-sm);
cursor: pointer;
transition: filter 0.15s cubic-bezier(0.45, 0, 0.55, 1);
contain: layout style paint;
&:hover {
filter: brightness(125%);
}
&:active {
filter: brightness(95%);
}
}

195
src/styles/main.scss Normal file
View File

@@ -0,0 +1,195 @@
@import "tailwindcss";
@theme {
--color-accent: oklch(57.93% 0.258 294.12);
--color-success: oklch(70.19% 0.158 160.44);
--color-error: oklch(53% 0.251 28.48);
--color-base: oklch(11% 0.007 285);
--color-surface: oklch(19% 0.007 285.66);
--color-overlay: oklch(26% 0.008 285.66);
--color-muted: oklch(63% 0.015 286);
--color-subtle: oklch(72% 0.015 286);
--color-text: oklch(87% 0.015 286);
--color-highlight-sm: oklch(30.67% 0.007 286);
--color-highlight: oklch(39.26% 0.01 286);
--color-highlight-lg: oklch(47.72% 0.011 286);
}
@font-face {
font-family: "Instrument Sans";
src: url("/assets/fonts/InstrumentSans-VariableFont_wdth,wght.woff2")
format("woff2");
font-display: swap;
}
:root {
--default-font-family: "Instrument Sans", ui-sans-serif, system-ui,
sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
"Noto Color Emoji";
}
html {
line-height: normal;
color-scheme: dark;
color: var(--color-text);
}
@layer base {
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: 600;
}
h1 {
font-size: clamp(42px, 10vw, 64px);
}
h2 {
font-size: clamp(30px, 6vw, 36px);
}
h3 {
font-size: 1.25rem;
}
}
button {
cursor: pointer;
}
input:not(.search) {
@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"] {
@apply p-0 cursor-pointer;
&::file-selector-button {
@apply px-2 py-2 mr-1 bg-highlight text-subtle cursor-pointer;
}
}
}
.link-card {
background: var(--color-overlay);
display: flex;
flex-direction: row;
text-decoration: none;
border-radius: 1rem;
padding: 0.625rem;
align-items: center;
transition-property: box-shadow, transform, translate;
transition-duration: 150ms;
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);
contain: layout style paint;
&:not(.admin) {
&:hover {
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 {
box-shadow: 0 2px 4px -2px rgb(0 0 0 / 0.1);
transform: translateY(2px);
}
}
}
@media (prefers-reduced-motion: reduce) {
.link-card {
transition: none;
&:hover {
transform: none;
}
&:active {
transform: none;
}
}
}
/* Div that holds the image */
.link-card div[data-img-container] {
flex-shrink: 0;
padding-right: 0.5rem;
}
.link-card div[data-img-container] img {
user-select: none;
border-radius: 0.375rem;
aspect-ratio: 1/1;
object-fit: cover;
}
/* Div that holds the text */
.link-card div[data-text-container] {
display: flex;
flex-grow: 1;
flex-direction: column;
row-gap: 1px;
overflow: hidden;
word-break: break-all;
}
.link-card div[data-text-container] p {
color: var(--color-subtle);
white-space: pre-wrap;
border: 1px solid #0000;
min-height: 22px;
}
.new-link-card {
display: flex;
flex-direction: row;
align-items: center;
padding: 0.625rem;
border: 0.125rem dashed var(--color-subtle);
border-radius: 1rem;
transition: box-shadow, transofrm 150ms cubic-bezier(0.45, 0, 0.55, 1);
cursor: pointer;
user-select: none;
&:hover {
text-decoration: underline;
}
}
.categoy-header {
display: flex;
align-items: center;
}
.category-header div[data-img-container] {
@apply shrink-0 relative mr-2 h-full flex items-center justify-center size-8;
}
.categoy-header div[data-img-container] img {
user-select: none;
object-fit: cover;
aspect-ratio: 1/1;
}
.category-header h2 {
text-transform: capitalize;
word-break: break-all;
border-width: 1px;
border-color: #0000;
}
.link-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(330px, 100%), 1fr));
gap: 0.5rem;
padding: 0.625rem;
contain: layout style paint;
}

View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Passport</title>
<link rel="favicon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preload" as="font" type="font/woff2" crossorigin="anonymous"
href="/assets/fonts/InstrumentSans-VariableFont_wdth,wght.woff2" />
{{{embedFile "assets/tailwind.css"}}}
{{{embedFile "styles/adminUi.css"}}}
</head>
<body class="bg-surface text-text">
{{embed}}
</body>
{{{devContent}}}
</html>

View File

@@ -3,14 +3,17 @@
<head>
<title>Passport</title>
<link rel="favicon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="preload" as="font" type="font/woff2" crossorigin="anonymous" href="/assets/fonts/InstrumentSans-VariableFont_wdth,wght.woff2">
<style>{{{inlineCSS}}}</style>
<link rel="favicon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preload" as="font" type="font/woff2" crossorigin="anonymous"
href="/assets/fonts/InstrumentSans-VariableFont_wdth,wght.woff2" />
{{{embedFile "assets/tailwind.css"}}}
</head>
<body class="bg-[#151316] text-white">
<body class="bg-surface text-text">
{{embed}}
</body>
{{{devContent}}}
</html>

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,208 @@
<div id="blur-target"
class="transition-[filter] motion-reduce:transition-none ease-[cubic-bezier(0.45,0,0.55,1)] duration-300">
<header class="flex w-full p-3">
<a href="/"
class="flex items-center flex-row gap-2 text-white border-b hover:border-transparent justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20"
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="m9 14l-4-4l4-4" />
<path d="M5 10h11a4 4 0 1 1 0 8h-1" />
</g>
</svg>
Return to home
</a>
</header>
<section class="flex justify-center w-full">
<div class="w-full sm:w-4/5 p-2.5">
{{#each Categories}}
<div class="flex items-center category-header" id="{{this.ID}}_category">
<div class="category-img" data-img-container>
<img width="32" height="32" draggable="false" alt="{{this.Name}}" src="{{this.Icon}}" />
</div>
<h2 data-placeholder="Enter title...">{{~ this.Name ~}}</h2>
<div class="pl-2" data-edit-actions>
<div class="flex flex-row gap-2" data-primary-actions>
<button aria-label="Edit category" onclick="editCategory(this)" class="action-button">
<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)"
class="text-error action-button">
<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>
</div>
<div class="link-grid">
{{#each this.Links}}
<div id="{{this.ID}}_link" class="link-card relative admin">
<div class="relative" data-img-container>
<img width="64" height="64" draggable="false" src="{{this.Icon}}" alt="{{this.Name}}" />
</div>
<div data-text-container>
<h3 class="border border-transparent" data-placeholder="Enter title...">
{{~ this.Name ~}}
</h3>
<!-- add 2 to the height to account for the border -->
<p data-placeholder="Enter description...">
{{~ this.Description ~}}
</p>
</div>
<div class="absolute right-1 top-1" data-edit-actions>
<div class="flex flex-row gap-2" data-primary-actions>
<button aria-label="Edit link" onclick="editLink(this)" class="action-button">
<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)"
class="text-error action-button">
<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>
</div>
{{/each}}
<div onclick="openModal('link', {{this.ID}})" class="new-link-card">
<svg class="mr-2" xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M12 5v14m-7-7h14" />
</svg>
<div>
<h3>Add a link</h3>
</div>
</div>
</div>
{{/each}}
<div class="flex items-center" id="add-category-button">
<svg class="mr-2" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M12 5v14m-7-7h14" />
</svg>
<h2 onclick="openModal('category')" class="text-subtle underline decoration-dashed cursor-pointer">
Add a new category
</h2>
</div>
</div>
</section>
</div>
<input type="file" id="icon-upload" accept="image/*" style="display: none;" />
<div id="modal-container" role="dialog" aria-modal="true"
class="modal-bg fixed top-0 left-0 bottom-0 right-0 bg-black/45 justify-center items-center hidden">
<div class="bg-overlay rounded-xl overflow-hidden w-full p-4 modal max-w-sm">
{{> 'partials/modals/category-form' }}
{{> 'partials/modals/link-form' }}
{{> 'partials/modals/delete-link' }}
{{> 'partials/modals/delete-category' }}
</div>
</div>
<!-- store a blank link card so that if we add a new link we can clone it to make the editing experience easier -->
<div id="template-link-card" class="hidden">
<div class="relative" data-img-container>
<img width="64" height="64" draggable="false" />
</div>
<div class="flex-grow flex flex-col gap-y-px overflow-hidden" data-text-container>
<h3 class="border border-transparent"></h3>
<!-- add 2 to the height to account for the border -->
<p class="min-h-[22px] border border-transparent"></p>
</div>
</div>
<div id="template-category" class="hidden">
<div class="flex items-center category-header">
<div class="category-img" data-img-container>
<img width="32" height="32" draggable="false" />
</div>
<h2></h2>
</div>
<div class="link-grid">
<div class="new-link-card">
<svg class="mr-2" xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 5v14m-7-7h14" />
</svg>
<div>
<h3>Add a link</h3>
</div>
</div>
</div>
</div>
<div id="template-edit-actions" class="hidden" data-edit-actions>
<div class="flex flex-row gap-2" data-primary-actions>
<button class="action-button">
<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 class="text-error action-button">
<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>
<div id="teleport-storage" class="absolute -top-full -left-full hidden">
<!-- These are the elements that appear when the user enters edit mode, they allow for the cancelation/confirmation of the edit -->
<div class="flex flex-row gap-2" data-confirm-actions id="confirm-actions">
<button class="action-button text-success" onclick="confirmEdit()">
<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 class="action-button text-error" onclick="cancelEdit()">
<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>
<!-- This is the element that appears on top of the icon when the user is editing it that allows for changing the icon -->
<button id="select-icon-button" onclick="selectIcon()"
class="flex absolute inset-0 bg-highlight/80 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>
{{{embedFile "scripts/admin.js"}}}

View File

@@ -1,5 +1,5 @@
<main class="flex justify-center items-center h-screen relative bg-[#0E0A0E]">
<div class="flex bg-[#151316] rounded-xl overflow-hidden">
<main class="flex justify-center items-center h-screen relative bg-base">
<div class="flex bg-surface rounded-xl overflow-hidden">
<img src="/assets/leaves.webp" class="h-96 w-64 object-cover" />
<div class="flex flex-col p-4 text-center">
<h2 class="text-2xl">
@@ -8,8 +8,7 @@
<form action="/admin/login" method="post" class="flex flex-col gap-y-3 my-2">
<input type="text" name="username" placeholder="Username" />
<input type="password" name="password" placeholder="Password" />
<button class="px-4 py-2 rounded-md w-full bg-[#8A42FF] text-white border-0"
type="submit">Login</button>
<button class="px-4 py-2 rounded-md w-full bg-accent text-white border-0" type="submit">Login</button>
</form>
<span id="message"></span>
</div>

View File

@@ -1,8 +1,8 @@
<main class="grid grid-rows-3 grid-cols-[1fr] justify-center items-center h-screen bg-[#0E0A0E]">
<main class="grid grid-rows-3 grid-cols-[1fr] justify-center items-center h-screen bg-base">
<div class="flex h-full p-2.5 justify-between">
<div>
{{#if WeatherData}}
<div class="text-[#BABABA] flex items-center">
<div class="text-subtle flex items-center">
<span class="mr-2 flex items-center">
{{{WeatherData.Icon}}}
</span>
@@ -15,20 +15,25 @@
</div>
<div>
{{#if UptimeData}}
<div class="text-[#BABABA] flex items-end flex-col">
<div class="text-subtle flex items-end flex-col">
{{#each UptimeData}}
<div class="flex items-center">
<span class="mr-2 flex items-center">
{{{this.FriendlyName}}}
</span>
<div class="relative my-auto">
{{#if (eq this.Status 2)}}
<span class="absolute w-2 h-2 rounded-full bg-emerald-400 animate-ping block"></span>
<span class="relative w-2 h-2 rounded-full bg-emerald-500 block"></span>
{{else}}
<span class="absolute w-2 h-2 rounded-full bg-rose-400 animate-ping block"></span>
<span class="relative w-2 h-2 rounded-full bg-rose-500 block"></span>
{{/if}}
<div class="relative my-auto size-2">
<div class="relative my-auto size-2 flex-shrink-0 flex-grow-0">
<svg class="absolute w-full h-full animate-ping" viewBox="0 0 10 10">
<circle cx="5" cy="5" r="5"
class="fill-current {{#if (eq this.Status 2)}} text-success {{else}} text-error {{/if}}">
</circle>
</svg>
<svg class="relative w-full h-full" viewBox="0 0 10 10">
<circle cx="5" cy="5" r="5"
class="fill-current {{#if (eq this.Status 2)}} text-success {{else}} text-error {{/if}}">
</circle>
</svg>
</div>
</div>
</div>
{{/each}}
@@ -62,32 +67,32 @@
</div>
<form class="w-full max-w-3xl" action="{{ SearchProviderURL }}" method="GET">
<input name="{{ SearchParam }}" aria-label="Search bar"
class="w-full bg-[#1C1C21] border border-[#2E2E32] rounded-full px-3 py-1 text-white h-7 focus-visible:outline-none placeholder:italic placeholder:text-[#434343] search"
class="w-full bg-surface border border-highlight-sm/70 rounded-full px-3 py-1 text-white h-7 focus-visible:outline-none placeholder:italic placeholder:text-highlight search"
placeholder="Search..." />
</form>
</div>
</main>
<section class="flex justify-center w-full">
<section class="flex justify-center w-full bg-surface">
<div class="w-full sm:w-4/5 p-2.5">
{{#each Categories}}
<div class="flex items-center mt-2 first:mt-0">
<img class="object-contain mr-2 select-none" width="32" height="32" draggable="false" alt="{{this.Name}}"
src="{{this.Icon}}" />
<img class="object-contain mr-2 select-none size-8" width="32" height="32" draggable="false"
alt="{{this.Name}}" src="{{this.Icon}}" />
<h2 class="capitalize break-all">{{this.Name}}</h2>
</div>
<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="underline-none text-unset rounded-2xl bg-[#211F23] p-2.5 flex flex-row items-center shadow-md hover:shadow-xl transition-[shadow,transform,translate] ease-[cubic-bezier(0.16,1,0.3,1)] hover:-translate-y-1"
draggable="false" target="_blank" rel="noopener noreferrer">
<img class="mr-2 select-none rounded-md aspect-square object-cover" width="64" height="64"
draggable="false" src="{{this.Icon}}" alt="{{this.Name}}" />
<div class="break-all">
<div class="link-grid">
{{#each this.Links}}
<a href="{{this.URL}}" class="link-card" draggable="false" target="_blank" rel="noopener noreferrer">
<div data-img-container>
<img width="64" height="64" draggable="false" src="{{this.Icon}}" alt="{{this.Name}}" />
</div>
<div data-text-container>
<h3>{{this.Name}}</h3>
<p class="text-[#D7D7D7]">{{this.Description}}</p>
<p class="min-h-5">{{this.Description}}</p>
</div>
</a>
{{else}}
<p class="text-[#D7D7D7]">No links here, add one!</p>
<p class="text-subtle">No links here, add one!</p>
{{/each}}
</div>
{{/each}}

View File

@@ -1,49 +0,0 @@
@import "tailwindcss";
@font-face {
font-family: "Instrument Sans";
src: url("/assets/fonts/InstrumentSans-VariableFont_wdth,wght.woff2") format("woff2");
font-display: swap;
}
:root {
--default-font-family: "Instrument Sans", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
html {
line-height: normal;
color-scheme: dark;
}
h1,
h2,
h3,
h4,
h5,
h6 {
@apply font-semibold;
}
h1 {
font-size: clamp(42px, 10vw, 64px);
}
h2 {
font-size: clamp(30px, 6vw, 36px);
}
h3 {
font-size: 1.25rem;
}
input:not(.search) {
@apply px-4 py-2 rounded-md w-full bg-[#1C1C21] border border-[#2E2E32] placeholder:text-[#8A8E90] text-white focus-visible:outline-none transition-colors duration-300 ease-out overflow-hidden;
&[type="file"] {
@apply p-0 cursor-pointer;
&::file-selector-button {
@apply px-2 py-2 mr-1 bg-[hsl(240,6%,18%)] text-white cursor-pointer;
}
}
}

View File

@@ -1,272 +0,0 @@
<div id="blur-target" class="transition-[filter] ease-[cubic-bezier(0.45,0,0.55,1)] duration-300">
<header class="flex w-full p-3">
<a href="/"
class="flex items-center flex-row gap-2 text-white border-b hover:border-transparent justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20"
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="m9 14l-4-4l4-4" />
<path d="M5 10h11a4 4 0 1 1 0 8h-1" />
</g>
</svg>
Return to home
</a>
</header>
<section class="flex justify-center w-full">
<div class="w-full sm:w-4/5 p-2.5">
{{#each Categories}}
<div class="flex items-center" key="category-{{this.ID}}">
<img class="object-contain mr-2 select-none" width="32" height="32" draggable="false"
alt="{{this.Name}}" src="{{this.Icon}}" />
<h2 class="capitalize break-all">{{this.Name}}</h2>
<button onclick="deleteCategory({{this.ID}})"
class="w-fit h-fit flex p-0.5 bg-[#1C1C21] border-solid border-[#211F23] rounded-md hover:bg-[#29292e] cursor-pointer"><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 class="p-2.5 grid grid-cols-[repeat(auto-fill,_minmax(min(330px,_100%),_1fr))] gap-2">
{{#each this.Links}}
<div key="link-{{this.ID}}"
class="rounded-2xl bg-[#211F23] p-2.5 flex flex-row items-center shadow-md hover:shadow-xl transition-[shadow,transform,translate] ease-[cubic-bezier(0.16,1,0.3,1)] hover:-translate-y-1 relative">
<img class="mr-2 select-none rounded-md aspect-square object-cover" width="64" height="64"
draggable="false" src="{{this.Icon}}" alt="{{this.Name}}" />
<div class="break-all">
<h3>{{this.Name}}</h3>
<p class="text-[#D7D7D7]">{{this.Description}}</p>
</div>
<button onclick="deleteLink({{this.ID}}, {{this.CategoryID}})"
class="w-fit h-fit flex p-0.5 bg-[#1C1C21] border-solid border-[#211F23] rounded-md hover:bg-[#29292e] 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>
{{/each}}
<div onclick="openModal('link', {{this.ID}})"
class="rounded-2xl border border-dashed border-[#656565] p-2.5 flex flex-row items-center shadow-md hover:shadow-xl transition-[shadow,transform] ease-[cubic-bezier(0.16,1,0.3,1)] pointer-cursor select-none cursor-pointer">
<svg class="mr-2" xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M12 5v14m-7-7h14" />
</svg>
<div>
<h3>Add a link</h3>
</div>
</div>
</div>
{{/each}}
<div class="flex items-center">
<svg class="mr-2" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M12 5v14m-7-7h14" />
</svg>
<h2 onclick="openModal('category')" class="text-[#656565] underline decoration-dashed cursor-pointer">
Add a new category
</h2>
</div>
</div>
</section>
</div>
<div id="modal-container"
class="flex modal-bg fixed top-0 left-0 bottom-0 right-0 bg-[#00000070] justify-center items-center">
<div class="bg-[#151316] rounded-xl overflow-hidden w-fit p-4 modal">
<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" />
</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-[#8A42FF] 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-[#8A42FF] text-white border-0" type="submit">Add
link</button>
</form>
<span id="link-message"></span>
</div>
</div>
</div>
<script>
// idfk what this variable capitalization is, it's a mess
let modalContainer = document.getElementById("modal-container");
let modal = modalContainer.querySelector("div");
let pageElement = document.getElementById("blur-target");
let targetCategoryID = null;
let activeModal = null;
function openModal(modalKind, categoryID) {
activeModal = modalKind;
targetCategoryID = categoryID;
pageElement.style.filter = "blur(20px)";
document.getElementById(modalKind + "-contents").classList.remove("hidden");
modalContainer.classList.add("is-visible");
modal.classList.add("is-visible");
document.getElementById(modalKind + "-form").reset();
}
function closeModal() {
pageElement.style.filter = "";
modalContainer.classList.remove("is-visible");
modal.classList.remove("is-visible");
setTimeout(() => {
document.getElementById(activeModal + "-contents").classList.add("hidden");
activeModal = null;
}, 300)
document.getElementById(activeModal + "-form").querySelectorAll("[required]").forEach((el) => {
el.classList.remove("invalid:border-[#861024]!");
});
targetCategoryID = null;
}
modalContainer.addEventListener("click", (event) => {
if (event.target === modalContainer) {
closeModal();
}
});
async function deleteLink(linkID, categoryID) {
let res = await fetch(`/api/category/${categoryID}/link/${linkID}`, {
method: "DELETE"
});
if (res.status === 200) {
let linkEl = document.querySelector(`[key="link-${linkID}"]`);
linkEl.remove();
}
}
async function deleteCategory(categoryID) {
let res = await fetch(`/api/category/${categoryID}`, {
method: "DELETE"
});
if (res.status === 200) {
let categoryEl = document.querySelector(`[key="category-${categoryID}"]`);
// get the next element and remove it (its the link grid)
let nextEl = categoryEl.nextElementSibling;
nextEl.remove();
categoryEl.remove();
}
}
document.getElementById("link-form").querySelector("button").addEventListener("click", (event) => {
document.getElementById("link-form").querySelectorAll("[required]").forEach((el) => {
el.classList.add("invalid:border-[#861024]!");
});
});
document.getElementById("link-form").addEventListener("submit", async (event) => {
event.preventDefault();
let data = new FormData(event.target);
let res = await fetch(`/api/category/${targetCategoryID}/link`, {
method: "POST",
body: data
});
if (res.status === 201) {
closeModal('link');
document.getElementById("link-form").reset();
location.reload();
} else {
let json = await res.json();
document.getElementById("category-message").innerText = json.message;
}
});
document.getElementById("category-form").querySelector("button").addEventListener("click", (event) => {
document.getElementById("category-form").querySelectorAll("[required]").forEach((el) => {
el.classList.add("invalid:border-[#861024]!");
});
});
document.getElementById("category-form").addEventListener("submit", async (event) => {
event.preventDefault();
let data = new FormData(event.target);
let res = await fetch(`/api/category`, {
method: "POST",
body: data
});
if (res.status === 201) {
closeModal('category');
document.getElementById("category-form").reset();
location.reload();
} else {
let json = await res.json();
document.getElementById("link-message").innerText = json.message;
}
});
</script>
<style>
.modal-bg {
visibility: hidden;
opacity: 0;
transition: opacity 0.3s ease, visibility 0s 0.3s;
transition-timing-function: cubic-bezier(0.45, 0, 0.55, 1);
}
.modal-bg.is-visible {
visibility: visible;
opacity: 1;
transition-delay: 0s;
}
.modal {
opacity: 0;
transform: translateY(20px) scale(0.95);
transition: opacity 0.3s ease, transform 0.3s ease;
transition-timing-function: cubic-bezier(0.45, 0, 0.55, 1);
}
.modal.is-visible {
opacity: 1;
visibility: visible;
transform: translateY(0) scale(1);
transition-delay: 0s;
}
</style>

View File

@@ -1,6 +1,6 @@
{
"name": "passport",
"version": "0.2.0",
"version": "0.3.2",
"description": "Passport is a simple, lightweight, and fast dashboard/new tab page for your browser.",
"author": "juls0730",
"license": "BSL-1.0",
@@ -10,9 +10,9 @@
"url": "https://github.com/juls0730/passport.git"
},
"scripts": {
"dev": "go generate; PASSPORT_DEV_MODE=true go run main.go",
"build": "go generate && go build -tags netgo,prod -ldflags=\"-w -s\" -o passport"
"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 src/main.go"
},
"pattern": "**/*.go,templates/views/**/*.hbs,styles/**/*.css,assets/**/*.{svg,png,jpg,jpeg,webp,woff2,ttf,otf,eot,ico,gif,webp}",
"pattern": "src/**/*.{go,hbs,js,css,scss,svg,png,jpg,jpeg,webp,woff2,ico,webp}",
"shutdown_signal": "SIGINT"
}