13 Commits

Author SHA1 Message Date
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
Zoe
83512c3584 make workflow more generic
All checks were successful
Build and Push Docker Image to GHCR / build-and-push (push) Successful in 31s
2025-09-24 16:39:24 +00:00
Zoe
b75337f450 cleaned up templates a bit, and some style fixes 2025-09-24 15:39:16 +00:00
Zoe
770472c30c Reduce release size
Some checks failed
Build and Push Docker Image to GHCR / build-and-push (push) Failing after 2m42s
Switch to all pure go libraries to no longer depends on libc, allowing
us to use a static container image. Compress binary using UPX for an
addition 7MB
2025-09-23 16:21:05 +00:00
Zoe
8e6753ebea fixup docs and use zqdgr in the build process 2025-09-23 13:45:05 +00:00
Zoe
bc8b9d172b style changes, nicer link management, api reorg
Some checks are pending
Build and Push Docker Image to GHCR / build-and-push (push) Waiting to run
2025-09-22 21:31:35 -05:00
Zoe
a1e5346fdf fix(docker release): install tailwindcss and generate css files
Some checks are pending
Build and Push Docker Image to GHCR / build-and-push (push) Waiting to run
2025-09-22 19:00:12 -05:00
Zoe
5b8177bd12 improve database handling and category management, enhance admin UI with animations 2025-09-22 18:49:53 -05:00
Zoe
8c18e81358 docker releases 2025-09-22 18:49:05 -05:00
Zoe
0558c719d6 bump(deps): fiber 2025-09-22 18:32:58 -05:00
Zoe
d31f24c101 I wish it was impossible to make this mistake 2025-04-19 01:08:38 -05:00
26 changed files with 2320 additions and 768 deletions

42
.github/workflows/docker-publish.yml vendored Normal file
View File

@@ -0,0 +1,42 @@
name: Build and Push Docker Image to GHCR
env:
OCI_TOKEN: ${{ secrets.OCI_TOKEN || secrets.GITHUB_TOKEN }}
on:
push:
branches: ["main"]
tags: ["v*.*.*"]
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ vars.OCI_REGISTRY || 'ghcr.io' }}
username: ${{ github.actor }}
password: ${{ env.OCI_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ vars.OCI_REGISTRY || 'ghcr.io' }}/${{ github.repository }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

6
.gitignore vendored
View File

@@ -1,6 +1,8 @@
passport
.env
passport.db*
public/uploads/
public
zqdgr
assets/tailwind.css
# compiled via go prepare
src/assets/tailwind.css

31
Dockerfile Normal file
View File

@@ -0,0 +1,31 @@
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
RUN go install github.com/juls0730/zqdgr@latest
WORKDIR /app
ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN zqdgr build
RUN upx passport
# ---- Runtime Stage ----
FROM gcr.io/distroless/static-debian12 AS runner
WORKDIR /data
COPY --from=builder /app/passport /usr/local/bin/passport
EXPOSE 3000
CMD ["/usr/local/bin/passport"]

View File

@@ -17,47 +17,81 @@ Passport is a simple, fast, and lightweight web dashboard/new tab replacement.
## Usage
1. Clone the repository
2. Configure the `.env` file, an example is provided in the `.env.example` file, see below for every available environment variable
4. Deploy `passport` to your web server
5. profit
### Docker
Passport is available as a Docker image via this repository. This is the recommended way to run Passport.
```bash
docker run -d --name passport -p 3000:3000 -v passport_data:/data -e PASSPORT_ADMIN_USERNAME=admin -e PASSPORT_ADMIN_PASSWORD=password ghcr.io/juls0730/passport:latest
```
Make sure to change the admin password to something secure. At `/data` is where all of passport's persistent data will be stored, such as image uploads and the sqlite database.
### Building from source
If you want to build from source, you will need to install the dependencies first.
```bash
# note entirely necessary, but strongly recommended
go install github.com/juls0730/zqdgr@latest
curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.13/tailwindcss-linux-x64
chmod +x tailwindcss-linux-x64
mv tailwindcss-linux-x64 /usr/local/bin/tailwindcss
# you may also have to install sqlite3...
```
Then you can build the binary.
```bash
zqdgr build
```
You can then run the binary.
### Configuration
#### Passport configuration
| Environment Variable | Description | Required | Default |
| --- | --- | --- | --- |
| -------------------------------------- | ------------------------------------------------------------------------------- | -------- | ------- |
| `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 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 | if enabled | |
| `OPENWEATHER_TEMP_UNITS` | The temperature units to use, either `metric` or `imperial` | false | metric |
| `OPENWEATHER_LAT` | The latitude of your location | if enabled | |
| `OPENWEATHER_LON` | The longitude of your location | if enabled | |
| `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 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 | if enabled | |
| `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
The admin dashboard can be accessed at `/admin`, you will be redirected to the login page if you are not logged in, use the credentials you configured in the `.env` file to login. Once logged in you can add links and categories.
The admin dashboard can be accessed at `/admin`, you will be redirected to the login page if you are not logged in, use
the credentials you configured via the environment variables to login. Once logged in you can add links and categories.
## License

56
go.mod
View File

@@ -1,42 +1,50 @@
module github.com/juls0730/passport
go 1.23.2
require github.com/caarlos0/env/v11 v11.3.1
go 1.25.0
require (
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/gofiber/schema v1.2.0 // indirect
github.com/mailgun/raymond/v2 v2.0.48 // indirect
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
github.com/sirupsen/logrus v1.8.1 // indirect
github.com/tinylib/msgp v1.2.5 // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/net v0.31.0 // indirect
golang.org/x/text v0.21.0 // indirect
github.com/HugoSmits86/nativewebp v1.2.0
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
)
require (
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/chai2010/webp v1.1.1
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gofiber/schema v1.6.0 // indirect
github.com/mailgun/raymond/v2 v2.0.48 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
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/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/net v0.44.0 // indirect
golang.org/x/text v0.29.0 // indirect
modernc.org/libc v1.66.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
require (
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/gofiber/fiber/v2 v2.52.5 // indirect
github.com/gofiber/fiber/v3 v3.0.0-beta.4
github.com/gofiber/fiber/v3 v3.0.0-rc.1
github.com/gofiber/template v1.8.3 // indirect
github.com/gofiber/template/handlebars/v2 v2.1.10
github.com/gofiber/utils v1.1.0 // indirect
github.com/gofiber/utils/v2 v2.0.0-beta.7 // indirect
github.com/gofiber/utils/v2 v2.0.0-rc.1 // indirect
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/klauspost/compress v1.17.11 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/klauspost/compress v1.18.0 // indirect
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/mattn/go-sqlite3 v1.14.24
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
github.com/rivo/uniseg v0.2.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.58.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
golang.org/x/sys v0.28.0 // indirect
github.com/valyala/fasthttp v1.66.0 // indirect
golang.org/x/sys v0.36.0 // indirect
)

124
go.sum
View File

@@ -1,86 +1,130 @@
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/HugoSmits86/nativewebp v1.2.0 h1:XJtXeTg7FsOi9VB1elQYZy3n6VjYLqofSr3gGRLUOp4=
github.com/HugoSmits86/nativewebp v1.2.0/go.mod h1:YNQuWenlVmSUUASVNhTDwf4d7FwYQGbGhklC8p72Vr8=
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=
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
github.com/chai2010/webp v1.1.1 h1:jTRmEccAJ4MGrhFOrPMpNGIJ/eybIgwKpcACsrTEapk=
github.com/chai2010/webp v1.1.1/go.mod h1:0XVwvZWdjjdxpUEIf7b9g9VkHFnInUSYujwqTLEuldU=
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/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
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=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gofiber/fiber/v2 v2.52.5 h1:tWoP1MJQjGEe4GB5TUGOi7P2E0ZMMRx5ZTG4rT+yGMo=
github.com/gofiber/fiber/v2 v2.52.5/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ=
github.com/gofiber/fiber/v3 v3.0.0-beta.4 h1:KzDSavvhG7m81NIsmnu5l3ZDbVS4feCidl4xlIfu6V0=
github.com/gofiber/fiber/v3 v3.0.0-beta.4/go.mod h1:/WFUoHRkZEsGHyy2+fYcdqi109IVOFbVwxv1n1RU+kk=
github.com/gofiber/schema v1.2.0 h1:j+ZRrNnUa/0ZuWrn/6kAtAufEr4jCJ+JuTURAMxNSZg=
github.com/gofiber/schema v1.2.0/go.mod h1:YYwj01w3hVfaNjhtJzaqetymL56VW642YS3qZPhuE6c=
github.com/gofiber/fiber/v3 v3.0.0-rc.1 h1:034MxesK6bqGkidP+QR+Ysc1ukOacBWOHCarCKC1xfg=
github.com/gofiber/fiber/v3 v3.0.0-rc.1/go.mod h1:hFdT00oT0XVuQH1/z2i5n1pl/msExHDUie1SsLOkCuM=
github.com/gofiber/schema v1.6.0 h1:rAgVDFwhndtC+hgV7Vu5ItQCn7eC2mBA4Eu1/ZTiEYY=
github.com/gofiber/schema v1.6.0/go.mod h1:WNZWpQx8LlPSK7ZaX0OqOh+nQo/eW2OevsXs1VZfs/s=
github.com/gofiber/template v1.8.3 h1:hzHdvMwMo/T2kouz2pPCA0zGiLCeMnoGsQZBTSYgZxc=
github.com/gofiber/template v1.8.3/go.mod h1:bs/2n0pSNPOkRa5VJ8zTIvedcI/lEYxzV3+YPXdBvq8=
github.com/gofiber/template/handlebars/v2 v2.1.10 h1:Qc+uUMULCqW60LF4VKO1REpiyDAUy3vqW7xq66FPJGM=
github.com/gofiber/template/handlebars/v2 v2.1.10/go.mod h1:84WH9st5OJi255EGjuMAOqUVQ+Q2jUNhUKYbS5DgAcI=
github.com/gofiber/utils v1.1.0 h1:vdEBpn7AzIUJRhe+CiTOJdUcTg4Q9RK+pEa0KPbLdrM=
github.com/gofiber/utils v1.1.0/go.mod h1:poZpsnhBykfnY1Mc0KeEa6mSHrS3dV0+oBWyeQmb2e0=
github.com/gofiber/utils/v2 v2.0.0-beta.7 h1:NnHFrRHvhrufPABdWajcKZejz9HnCWmT/asoxRsiEbQ=
github.com/gofiber/utils/v2 v2.0.0-beta.7/go.mod h1:J/M03s+HMdZdvhAeyh76xT72IfVqBzuz/OJkrMa7cwU=
github.com/gofiber/utils/v2 v2.0.0-rc.1 h1:b77K5Rk9+Pjdxz4HlwEBnS7u5nikhx7armQB8xPds4s=
github.com/gofiber/utils/v2 v2.0.0-rc.1/go.mod h1:Y1g08g7gvST49bbjHJ1AVqcsmg93912R/tbKWhn6V3E=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/mailgun/raymond/v2 v2.0.48 h1:5dmlB680ZkFG2RN/0lvTAghrSxIESeu9/2aeDqACtjw=
github.com/mailgun/raymond/v2 v2.0.48/go.mod h1:lsgvL50kgt1ylcFJYZiULi5fjPBkkhNfj4KA0W54Z18=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
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.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY=
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
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/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=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
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=
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tinylib/msgp v1.2.5 h1:WeQg1whrXRFiZusidTQqzETkRpGjFjcIhW6uqWH09po=
github.com/tinylib/msgp v1.2.5/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
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/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=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.58.0 h1:GGB2dWxSbEprU9j0iMJHgdKYJVDyjrOwF9RE59PbRuE=
github.com/valyala/fasthttp v1.58.0/go.mod h1:SYXvHHaFp7QZHGKSHmoMipInhrI5StHrhDTYVEjK/Kw=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
github.com/valyala/fasthttp v1.66.0 h1:M87A0Z7EayeyNaV6pfO3tUTUiYO0dZfEJnRGXTVNuyU=
github.com/valyala/fasthttp v1.66.0/go.mod h1:Y4eC+zwoocmXSVCB1JmhNbYtS7tZPRI2ztPB72EVObs=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo=
golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM=
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=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
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=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY=
modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

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

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,8 @@ package middleware
import (
"database/sql"
"fmt"
"log/slog"
"time"
"github.com/gofiber/fiber/v3"
@@ -27,12 +29,18 @@ func AdminMiddleware(db *sql.DB) func(c fiber.Ctx) error {
WHERE session_id = ?
`, sessionToken).Scan(&session.SessionID, &session.ExpiresAt)
if err != nil {
return c.Next()
slog.Error("Failed to check session", "error", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"message": fmt.Sprintf("Failed to check session: %v", err),
})
}
sessionExpiry, err := time.Parse("2006-01-02 15:04:05-07:00", session.ExpiresAt)
if err != nil {
return c.Next()
slog.Error("Failed to parse session expiry", "error", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"message": fmt.Sprintf("Failed to parse session expiry: %v", err),
})
}
if sessionExpiry.Before(time.Now()) {

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()
}

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

@@ -0,0 +1,135 @@
@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% .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.010 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);
}
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;
}
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);
&: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:has(img):first-child {
flex-shrink: 0;
margin-right: 0.5rem;
}
.link-card div:first-child img {
user-select: none;
border-radius: 0.375rem;
aspect-ratio: 1/1;
object-fit: cover;
}
/* Div that holds the text */
.link-card div:nth-child(2) {
word-break: break-all;
}
.link-card div:nth-child(2) p {
color: var(--color-subtle);
}

View File

@@ -9,7 +9,7 @@
<style>{{{inlineCSS}}}</style>
</head>
<body class="bg-[#151316] text-white">
<body class="bg-surface text-text">
{{embed}}
</body>
{{{devContent}}}

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,859 @@
<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" key="category-{{this.ID}}">
<div class="shrink-0 relative mr-2 h-full flex items-center justify-center">
<img class="object-contain select-none" width="32" height="32" draggable="false" alt="{{this.Name}}"
src="{{this.Icon}}" />
<button onclick="selectIcon()"
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>
<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 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="link-card relative admin">
<div class="relative">
<img width="64" height="64" draggable="false" src="{{this.Icon}}" alt="{{this.Name}}" />
<button onclick="selectIcon()"
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>
{{/each}}
<div onclick="openModal('link', {{this.ID}})"
class="rounded-2xl border border-dashed border-subtle p-2.5 flex flex-row items-center hover:underline transition-[box-shadow,transform] ease-[cubic-bezier(0.45,0,0.55,1)] duration-150 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-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="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">
{{> 'partials/modals/category-form' }}
{{> 'partials/modals/link-form' }}
{{> 'partials/modals/delete-link' }}
{{> 'partials/modals/delete-category' }}
</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 iconUploader = document.getElementById("icon-upload");
let targetCategoryID = 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) {
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");
if (document.getElementById(modalKind + "-form") !== null) {
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)
if (document.getElementById(activeModal + "-form") !== null) {
document.getElementById(activeModal + "-form").querySelectorAll("[required]").forEach((el) => {
el.classList.remove("invalid:border-[#861024]!");
});
}
targetCategoryID = null;
}
let currentlyEditingLink = {
ID: null,
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;
}
let res = await fetch(`/api/category/${categoryID}/link/${linkID}`, {
method: "PATCH",
body: formData
});
if (res.status === 200) {
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 cancelLinkEdit(linkID, categoryID, text = undefined, description = undefined) {
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"
});
if (res.status === 200) {
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)
let nextEl = categoryEl.nextElementSibling;
nextEl.remove();
categoryEl.remove();
closeModal();
}
}
/**
* Replaces an H2 element with a resizable input field that matches its initial text and styling.
* @param {HTMLElement} targetEl The element to replace.
*/
function replaceWithResizableInput(targetEl) {
const originalText = targetEl.textContent;
const computedStyle = window.getComputedStyle(targetEl);
const inputElement = document.createElement('input');
inputElement.type = 'text';
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.display = 'inline-block';
inputElement.style.backgroundColor = 'var(--color-base)';
inputElement.style.border = '1px solid var(--color-highlight-sm)';
inputElement.style.borderRadius = '0.375rem';
inputElement.maxLength = 50;
/**
* 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";
}
setTimeout(() => resize(), 0);
inputElement.addEventListener('input', () => resize());
targetEl.parentNode.replaceChild(inputElement, targetEl);
inputElement.focus();
}
function restoreElementFromInput(inputEl, originalText) {
const computedStyle = window.getComputedStyle(inputEl);
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];
});
inputEl.parentNode.replaceChild(newElement, inputEl);
}
</script>
<style>
.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;
}
}
</style>

View File

@@ -1,19 +1,14 @@
<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">
Login
</h2>
<form action="/admin/login" method="post" class="flex flex-col gap-y-3 my-2">
<input
class="px-4 py-2 rounded-md w-full bg-[#1C1C21] border border-[#56565b]/30 text-white focus-visible:outline-none"
type="text" name="username" placeholder="Username" />
<input
class="px-4 py-2 rounded-md w-full bg-[#1C1C21] border border-[#56565b]/30 text-white focus-visible:outline-none"
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>
<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-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,31 +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-[#56565b]/30 rounded-full px-3 py-1 text-white h-7 focus-visible:outline-none placeholder:italic placeholder:text-[#434343]"
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 w-fit">
<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}}" />
<h2 class="capitalize w-fit">{{this.Name}}</h2>
<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="object-contain mr-2 select-none rounded-md" width="64" height="64" draggable="false"
src="{{this.Icon}}" alt="{{this.Name}}" />
{{#each this.Links}} <a href="{{this.URL}}" class="link-card" draggable="false" target="_blank"
rel="noopener noreferrer">
<div>
<img width="64" height="64" draggable="false" src="{{this.Icon}}" alt="{{this.Name}}" />
</div>
<div>
<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-subtle">No links here, add one!</p>
{{/each}}
</div>
{{/each}}

View File

@@ -1,37 +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;
}

View File

@@ -1,203 +0,0 @@
<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">
<img class="object-contain mr-2 select-none" width="32" height="32" draggable="false" alt="{{this.Name}}"
src="{{this.Icon}}" />
<h2 class="capitalize">{{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
class="rounded-2xl bg-[#211F23] 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)] relative">
<img class="object-contain mr-2 select-none rounded-md" width="64" height="64" draggable="false"
src="{{this.Icon}}" alt="{{this.Name}}" />
<div>
<h3>{{this.Name}}</h3>
<p class="text-[#D7D7D7]">{{this.Description}}</p>
</div>
<button onclick="deleteLink({{this.ID}})"
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="addLink({{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="addCategory()" class="text-[#656565] underline decoration-dashed cursor-pointer">
Add a new category
</h2>
</div>
</div>
</section>
<div id="linkModal" class="hidden absolute 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">
<h3>Add A link</h3>
<form id="link-form" action="/api/links" method="post" class="flex flex-col gap-y-3 my-2">
<div>
<label for="linkName">Name</label>
<input
class="px-4 py-2 rounded-md w-full bg-[#1C1C21] border border-[#56565b]/30 text-white focus-visible:outline-none"
type="text" name="name" placeholder="Name" id="linkName" />
</div>
<div>
<label for="linkDesc">Description</label>
<input
class="px-4 py-2 rounded-md w-full bg-[#1C1C21] border border-[#56565b]/30 text-white focus-visible:outline-none"
type="text" name="description" placeholder="Description" id="linkDesc" />
</div>
<div>
<label for="linkURL">URL</label>
<input
class="px-4 py-2 rounded-md w-full bg-[#1C1C21] border border-[#56565b]/30 text-white focus-visible:outline-none"
type="text" name="url" placeholder="URL" id="linkURL" />
</div>
<div>
<label for="linkIcon">Icon</label>
<input class="w-full text-white" 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</button>
</form>
<span id="link-message"></span>
</div>
</div>
<div id="categoryModal"
class="hidden absolute 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">
<h3>Create A category</h3>
<form id="category-form" action="/api/categories" method="post" class="flex flex-col gap-y-3 my-2">
<div>
<label for="categoryName">Name</label>
<input
class="px-4 py-2 rounded-md w-full bg-[#1C1C21] border border-[#56565b]/30 text-white focus-visible:outline-none"
type="text" name="name" placeholder="Name" id="categoryName" />
</div>
<div>
<label for="linkIcon">Icon</label>
<input class="w-full text-white" type="file" name="icon" id="linkIcon" accept=".svg" />
</div>
<button class="px-4 py-2 rounded-md w-full bg-[#8A42FF] text-white border-0" type="submit">Create</button>
</form>
<span id="category-message"></span>
</div>
</div>
<script>
// idfk what this variable capitalization is, it's a mess
let linkModal = document.getElementById("linkModal");
let categoryModal = document.getElementById("categoryModal");
let targetCategoryID = null;
function addCategory() {
categoryModal.classList.remove("hidden");
categoryModal.classList.add("flex");
}
function addLink(categoryID) {
targetCategoryID = categoryID;
linkModal.classList.remove("hidden");
linkModal.classList.add("flex");
}
async function deleteLink(linkID) {
let res = await fetch(`/api/links/${linkID}`, {
method: "DELETE"
});
if (res.status === 200) {
location.reload();
}
}
async function deleteCategory(categoryID) {
let res = await fetch(`/api/categories/${categoryID}`, {
method: "DELETE"
});
if (res.status === 200) {
location.reload();
}
}
document.getElementById("link-form").addEventListener("submit", async (event) => {
event.preventDefault();
let data = new FormData(event.target);
data.append("category_id", targetCategoryID);
let res = await fetch(`/api/links`, {
method: "POST",
body: data
});
if (res.status === 201) {
linkModal.classList.add("hidden");
linkModal.classList.remove("flex");
document.getElementById("link-form").reset();
location.reload();
} else {
let json = await res.json();
document.getElementById("category-message").innerText = json.message;
}
});
document.getElementById("category-form").addEventListener("submit", async (event) => {
event.preventDefault();
let data = new FormData(event.target);
let res = await fetch(`/api/categories`, {
method: "POST",
body: data
});
if (res.status === 201) {
categoryModal.classList.add("hidden");
categoryModal.classList.remove("flex");
document.getElementById("category-form").reset();
location.reload();
} else {
let json = await res.json();
document.getElementById("link-message").innerText = json.message;
}
});
linkModal.addEventListener("click", (event) => {
if (event.target === linkModal) {
targetCategoryID = null;
linkModal.classList.add("hidden");
linkModal.classList.remove("flex");
}
});
categoryModal.addEventListener("click", (event) => {
if (event.target === categoryModal) {
targetCategoryID = null;
categoryModal.classList.add("hidden");
categoryModal.classList.remove("flex");
}
});
</script>

View File

@@ -1,6 +1,6 @@
{
"name": "passport",
"version": "0.2.0",
"version": "0.3.0",
"description": "Passport is a simple, lightweight, and fast dashboard/new tab page for your browser.",
"author": "juls0730",
"license": "BSL-1.0",
@@ -10,8 +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 -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,scss,svg,png,jpg,jpeg,webp,woff2,ico,webp}",
"shutdown_signal": "SIGINT"
}